サイオステクノロジー(Tech.Lab)のブログ - TECH PLAY

TECH PLAY

サイオステクノロジー(Tech.Lab)

サイオステクノロジー(Tech.Lab) の技術ブログ

全772件

アプリを多言語対応(Internationalization / i18n)させようとしたりしたとき、直面する壁があります。それが「翻訳によるUI(画面)の崩れ」です。 日本語環境ではピッタリ綺麗に収まっていたボタンやヘッダーが、英語に変えた途端に文字がはみ出したり、不自然に改行されてしまったり……。そんな経験はありませんか? UI上の「文言(ことば)」は言語によって長さが異なります。今回は、初学者の方向けに、多言語化で起こりうるレイアウト崩れの原因と、その対策です。 なぜ言語が変わるとUIが崩れるのか? 最大の原因は「言語による文字数の違い」です。 一般的に、日本語のテキストを英語に翻訳すると、文字数は約1.5倍から2倍近く長くなると言われています。 例えば、アプリのヘッダーによくある「設定」という言葉を見てみましょう。 日本語: 設定(2文字) 英語: Settings(8文字) ドイツ語: Einstellungen(13文字) 逆に、英語をベースにデザインされたUIを日本語に翻訳すると、スカスカで間が抜けた印象を与えてしまうこともあります。 言語が変われば文字数も変わり、単語の区切り位置も変わります。1文字の幅も様々です。日本語環境だけで完璧に見える「ピクセルパーフェクト」な実装は、世界基準で見ると実はもろい状態なのです。 崩れを防ぐ!CSS実装の3つの対策 では、文字数の変化に強いUIを作るにはどうすればよいのでしょうか。すぐに実践できる、CSSの3つの具体的な対策を紹介します。 1. 固定の幅や高さを指定しない ボタンやコンテナに width: 120px や height: 40px のような固定値を指定するのは危険です。中の文字が想定より長くなった瞬間、枠から文字が溢れ出してしまいます。 代わりに、 padding で内側の余白を確保し、コンテンツ(文字)の量に応じてサイズが自動で広がるようにしましょう。どうしても最低限のサイズを保ちたい場合は width ではなく min-width や min-height を使うのがおすすめです。 2. テキストが溢れたときの挙動を決めておく スマホ画面のサイドメニューなど、どうしても幅が制限される場所では、文字がはみ出した際のルールをCSSで決めておきます。 .text-truncate { white-space: nowrap; /* 改行させない */ overflow: hidden; /* はみ出た部分を隠す */ text-overflow: ellipsis; /* 「...」で省略する */ } この3行をあてることで、文字が枠を超えても「アカウント情…(Account info…)」や「パスワードの変…(Change passw…)」のように三点リーダーで省略され、デザインの崩壊を防ぎつつ「続きがあること」をユーザーに伝えられます。 3. 改行位置をコントロールする 英語やドイツ語などの長い単語は、意図しない場所で枠を突き抜けたり、不自然に改行されたりすることがあります。 親要素に overflow-wrap: break-word; などを設定し、コンテナの端に到達した時点で適切に単語が折り返されるように設定しておきましょう。 デザイン段階から意識したい「可変長」への備え UIの崩れを防ぐには、実装時の工夫だけでなく、デザイン段階でのコミュニケーションも不可欠です。 もしあなたがデザイナーからFigmaなどのデザインカンプを受け取ったとき、そこに「ボタン」や「タイトル」という短いダミーテキストしか書かれていなかったら要注意です。 「このボタンのテキストは最大何文字くらいになりますか?」 「文字が長くなって2行に改行された場合、下の要素が押し下げられてもレイアウト上問題ないですか?」 このように、最悪のケース(一番文字が長くなる状態)を想定してデザイナーやPMと会話してみましょう。 開発段階で「ダミーテキストを意図的に長くしてテストする」という癖をつけると、開発終盤での手戻りを減らすことができます。 まとめ UI上の「文言」は、言語によって生き物のように長さを変えます。 日本語と他言語では、文字数や単語の長さが大きく変わる 固定サイズに頼らず、中身に応じて伸縮するCSSを書く 文字が溢れたときのルール(省略記号 ... や改行)をあらかじめ設定する 「文言の長さが変わっても壊れないUI」は多言語化以外に、ユーザーが文字の表示を大きく設定している(アクセシビリティ設定)際にも効果を発揮します。まずはご自身で作ったアプリのボタンやカードに、わざと長〜いテキストを入れて、どのように崩れるかテストしてみてくださいね。 カバー画像: Unsplash の Adolfo Félix が撮影した写真 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 文字でUIが崩れる?エンジニアが知っておきたい「多言語化」の第一歩 first appeared on SIOS Tech Lab .
自己紹介とインターン参加理由 自己紹介 はじめまして日本工学院八王子専門学校 ITスペシャリスト科3年の村田悠月です。 学校ではクラウドの分野を中心に勉強しており、 サーバーやインフラの仕組みに興味を持って日々知識を深めています。 なぜ数ある企業からここ(サイオス)の インターンシップを選んだのか 今回のインターンは学校経由で参加させていただきました。 参加を決めた背景には、これまで個人での学習が中心だったこともあり、 実務に近い形でチーム開発を経験してみたい という想いがありました。特に、これまで自己流でしか触れてこなかった Git操作をチーム の中で正しく使えるようになりたい という気持ちと、近年注目されている AI駆動開発を自 分の手で体感してみたい という2つの目標を持って参加しました。 インターンシップでの成果物 成果物の紹介 私の参加したAチームでは、就活一元管理ダッシュボード 「ShukatsuHub」 を開発しました。 理系大学院生(ペルソナ:修士1年・エントリー企業数20〜30社)が、複数の求人サイトやメールアカウントに散らばった選考情報を1画面で把握できるようにすることで、情報整理や連絡対応にかかる手間を減らして、浮いた時間を研究に充てられることを目指したプロダクトです。画面の最上部には常に「研究に充てられる時間」を表示し 、就活の手 間を減らすほど研究時間が増えることを数字で分かりやすく見せる設計 にしました。 自分の役割 開発は12機能をチームで分担する形で進め、私は ①ダッシュボード ②カレンダー ・ Googleカ レンダー連携を軸とした日程調整 ③タスク・締め切り管理 ④選考ステータス管理ボード ⑤ 企業の発見・マッチング ⑥モバイル対応 の6機能を担当しました。 中間発表では、自分の担当範囲に加えて 履歴書用のプロフィール機能やメール受信箱機能の説明・実機デモ も担当しました。 成果発表までの道のりと課題との向き合い方 10日間のインターンでは、オリエンテーションや企画検討から始まり、ペルソナ設定や設計を経て、5日目から本格的な開発に挑みました。後半は中間発表や社員面談を挟みつつ開発に集中し、最終日の成果発表まで駆け抜ける充実した日程でした。 企画段階では思うように話し合いが進まず作業が滞ったほか、開発初期にディレクトリ構成やファイルの役割分担を細かく決めていなかったことで、各自がブランチで実装する場所がずれてしまい、 大量のコンフリクトが発生しました。 また「今日はどこまで進めればいいのか」「果たして順調に進んでいるのか」が分からず、 不安を感じながら作業する時期もありました。 加えて、AIに実装の多くを任せていため、思うように機能が動かなかったときにどう修正すればいいのか、 AIにどんな指示を出せばいいのかが分からず苦労しました。 これらは、 仕様書にディレクトリ構成やファイルの役割を細かく明記することでコンフリ クトを最小限に抑え 、さらにチーム開発の開始時・昼休憩前・終業時の3つのタイミングで 「今日はここまで、午後はここから、明日はここまでやる」という具体的な目標をチー ムで話し合う ことで、進捗感覚をつかめるようになり乗り越えることができました。 成果発表の感想・気づき 初めてのチーム開発だったため、 開発と発表準備の適切な時間配分には最後まで頭を悩ま せました 。その中で大きな学びとなったのが、エンジニアの方からのフィードバックです。「デモを先に見せてから詳細を説明した方が伝わりやすい」「聞き手が知りたいポイントを意識すべき」という指摘にハッとさせられ、自分本位だった発表スタイルを猛省しました。 作るだけでなく「相手に届くように伝える」視点を得られた、非常に濃密な10日間 となりました。 チーム開発を通じて気づいたこと、学んだこと リモート開発の当初は「今喋っていいのか」「自分が何をすればいいのか」が分からず、作業がなかなか進みませんでした。しかし、 今の状況をこまめに報告し、仕様の不明点は 話し合い、1機能が終わるたびに全員でイメージ通りか確認するリズムを掴むことで乗り 越えられました。 もともと静かだったチームも中間発表を境に雑談が増え、和気あいあいとした雰囲気へと変化していきました。 メンバーには何度も助けられました。自分では「これで十分」と思った実装にも「このままだと動かなくなる」「こう動かせたらもっと便利」と意見をもらったり、「ここはこうした方が楽だよ」「ここは僕がやるよ」とアドバイスやカバーをしてくれたりと、随所で支えてもらいました。 開発面ではGitのコンフリクトや構成の不整合に苦戦しましたが、 ディレクトリ構成や役割 分担を仕様書に規定し、共通基盤を手にとる際はチームに宣言するルールを作って開発初 期にあった作業ブッキングの再発を防ぐことができました。 企画面でも、ハッカソン形式ならではの学びとして 、限られた時間で「何を作り、何を諦 めるか」という優先順位づけや、最初から完璧を目指さず「小さく作って積み上げていく 」やり方の大切さ を深く実感しました。 サイオステクノロジーの風土 インターンシップ中のプログラムにあった 「AI活用勉強会」や社内の空気感 が非常に心に残っています。 勉強会では、Claudeを最大限に活用するためのプロンプトのテクニックに加え、AIの精度低下を招く「コンテキストロット(文脈の劣化)」のメカニズムとその解決策について詳しく伺いました。 単にAIへタスクを丸投げするのではなく、情報を整理して文脈を正しく 伝えることの重み を学んだことで、開発中にAIへの指示がうまくいかず悩んでいた自分自身の進め方を見つめ直す良い機会となりました。 社内の雰囲気については、 リモートワークが当たり前に成り立っている文化 に驚きました。社員の皆さんが県外など多様な拠点から業務に取り組まれている働き方が新鮮でした。 どのサービスライン(SL)の方々も非常に温かく、気さくに接してくださる方 ばかりだったので、初めてのインターンシップで緊張していましたが、分からないことも迷わず相談できる素晴らしい環境に助けられました。 インターンを通しての最大の学びと今後の目標 10日間のインターンを通して、 1人で作業を完結させることが中心だった大学での学びか ら、「チームで連携しながら進める働き方」 へと意識が大きく変わりました。技術力だけがあれば良いわけではなく、伝え方や優先順位づけ、コミュニケーションの取り方といった力も同じくらい重要だと実感しました。 また、今回初めてしっかりとしたチーム開発を経験し、どこに時間がかかり、何を決めておくとスムーズに開発を行えるかなど多くのことを学びました。 今後のグループ開発でもこの経験を活かし、 聞く人に魅力的に見えるものづくりとプレゼ ンテーションを行っていきたいと思います。 これからインターンを受ける後輩へ向けたアドバイスや応援の一言! リモートでの開発は、最初は顔合わせも会話もあまりない中でコミュニケーションを取るのに慣れず難しく感じると思いますが、積極的に話せば話すだけ距離も縮まりますし、意見も出やすくなります。雑談でも構わないので、たくさん話してみてください! また発表資料の作成には、思っている以上に時間をかけてください。自分たちの伝えたいことがきちんと伝わるように準備することが大切です。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post 「自己完結」から「チームで届けるモノづくり」へ。技術・チーム・AIと向き合った10日間の軌跡(インターンシップ2026【1期Aチーム】) first appeared on SIOS Tech Lab .
  Nginxで学ぶ障害対応・トラブルシューティング体験会 第2回のHA Loungeは、 LifeKeeper技術認定資格(Linux版) を取得された皆さまを対象に、より実践的なスキルと理解を深めていただくためのハンズオンイベントを開催いたします。 今回は、PCから気軽に参加できるオンラインハンズオン! Webサーバーの定番である Nginx をテーマに、監視用スクリプトを「作成」し、意図的に環境を「障害発生(クラッシュ)」させ、出力ログを検証して「復旧」させるまでの一連の流れを楽しく学びます。   皆さまのご参加を心よりお待ちしております! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ■ 開催概要 日時:11月11日(水)18時30分~(開場:18時20分~) 場所:Zoom 対象: LifeKeeper技術認定資格(Linux版) を取得された皆さま ▼ 詳細・お申し込み・お題の投稿はこちらから https://ha-lounge.connpass.com/event/405865/ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ The post 11/11(水) 第2回 HA Lounge 開催決定! first appeared on SIOS Tech Lab .
今号では、前回の続きで Podman のオプションや、その他の便利なコマンドについて説明します! Podman のオプション コンテナ起動時 (podman run) の主要オプション -i (–interactive) コンテナの「標準入力」を開いたままにする ※キーボードから入力したコマンドをコンテナに送信できるようになる -t (–tty) ターミナル (tty) を割り当てる ※コンテナ内のプロンプト ([root@… /]# 等) が表示される -d (–detach) コンテナをバックグラウンドで実行 -p (–publish) ホストとコンテナのポートを転送 (例: -p 8080:80) –name コンテナに任意の名前を付与 –rm コンテナ停止時に自動でコンテナ本体を削除 コマンド実行例①:test という名前で UBI9 (RHEL9) コンテナを対話的に起動し、終了時にコンテナを自動削除 $ podman run -it --name test --rm registry.access.redhat.com/ubi9/ubi /bin/bash [root@da79e1a95cc3 /]# コマンド実行例②:Web サーバ (nginx) をバックグラウンドで運用、またホストの 8080 番ポートへのアクセスをコンテナの 80 番ポートへ転送 $ podman run -d -p 8080:80 --name my-web nginx ※実環境では、コンテナをバックグラウンドで動かしたり、ホスト側からアクセスできるようにポートを公開したりするのが一般的です。 コンテナの状態を確認 podman ps コマンドを実行すると、現在起動中のコンテナ一覧が表示されます。 $ podman ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 46c7263c0e01 docker.io/library/nginx:latest nginx -g daemon o... About a minute ago Up About a minute 0.0.0.0:8080->80/tcp my-web さらに podman ps -a のようにコマンドを変更すると、停止中のコンテナも含めたすべてのコンテナ一覧が表示されます。 $ podman ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 881b03c8b779 docker.io/jenkins/jenkins:2.346.3-lts-jdk11 6 days ago Exited (143) 5 days ago 0.0.0.0:8080->8080/tcp, 0.0.0.0:50000->50000/tcp my-jenkins-app 46c7263c0e01 docker.io/library/nginx:latest nginx -g daemon o... 5 minutes ago Up 5 minutes 0.0.0.0:8080->80/tcp my-web 他にも、podman ps コマンドには下記のオプションを付けることができます。 よく使われると思われる、一部のオプションのみご紹介します。 -q (–quiet) コンテナ ID のみ表示する -l (–latest) 最後に作成された 1件のコンテナのみを表示する -s (–size) コンテナのファイルサイズを追加して表示する コンテナイメージの管理 podman ps は作成・起動されたコンテナを一覧表示するのに対し、podman images はコンテナイメージ (コンテナのテンプレート) を一覧表示します。 podman run を実行すると、podman images にあるコンテナイメージを元に新しいコンテナが作成され、その状態が podman ps で確認できるようになります。 イメージの確認 podman images コマンドで、ローカルに保存されているコンテナイメージの一覧を表示します。 $ podman images REPOSITORY TAG IMAGE ID CREATED SIZE registry.access.redhat.com/ubi9/ubi latest c1e867810b08 43 hours ago 220 MB docker.io/library/nginx latest 38becec41bfa 9 days ago 165 MB docker.io/jenkins/jenkins 2.346.3-lts-jdk11 ba3f3e7db66d 4 years ago 470 MB イメージの削除 podman rmi コマンドで、コンテナイメージを削除します。 $ podman rmi 38becec41bfa Untagged: docker.io/library/nginx:latest Deleted: 38becec41bfad1ab0ac77eb1626c569fea6a8f39e59436880f61bdeed477226e 【補足】 コンテナイメージの削除時、指定したイメージから作成されたコンテナが存在すると下記のようなエラーが出力され、削除できません。 $ podman rmi 38becec41bfa Error: image used by 46c7263c0e01d32e96532ef6c891d9c8eab7bed93a80619dfa107651e053a42f: image is in use by a container: consider listing external containers and force-removing image この場合の対処としては、下記の 2通りの方法があります。 podman rm コマンドで該当のコンテナを削除 ※ podman rm については前号 を参照 podman rmi -f コマンドでコンテナイメージを強制的に削除 -f オプションを付けることでコンテナの手動削除をスキップし、コンテナイメージを強制的に削除します。 $ podman rmi -f 38becec41bfa Untagged: docker.io/library/nginx:latest Deleted: 38becec41bfad1ab0ac77eb1626c569fea6a8f39e59436880f61bdeed477226e ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 知っておくとちょっと便利!Podman によるコンテナ運用2 first appeared on SIOS Tech Lab .
こんにちは! 今月も「OSSのサポートエンジニアが気になった!OSSの最新ニュース」をお届けします。 日本のオープンソースコミュニティが集うイベント「LF Japan Community Day 2026 Fukuoka」が、11 月 5日 (木) 福岡・天神で開催されます。 LF Japan Community Day 2026 Fukuoka 開催のお知らせ https://www.linuxfoundation.org/ja-jp/news/lf-japan-community-day-2026-fukuoka Statcounter のデータから、アメリカにおけるデスクトップ向け OS のシェアで Linux が 18% を超えたことが分かりました。 Linux のシェアは同年 5月あたりから急激に伸びています。 アメリカでLinuxのユーザーが18%を超える https://gigazine.net/news/20260902-statcounter-os-market-share/ 警察庁より、サイバー犯罪やサイバー攻撃等のサイバー空間における脅威について、最新の統計データが公開されました。 令和8年上半期におけるサイバー空間をめぐる脅威の情勢等について https://www.npa.go.jp/news/release/2026/20260909001.html ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 【2026年9月】OSSサポートエンジニアが気になった!OSS最新ニュース first appeared on SIOS Tech Lab .
Webやアプリケーション開発において「アクセシビリティ」や「ユーザー補助」という言葉を聞くと、皆さんはどう感じるでしょうか。「重要だとはわかっているけれど、開発コストがかかる」「一部のユーザーのための特別な対応」といったイメージを持つ方も少なくないかもしれません。 しかし、技術的な観点からアクセシビリティの構造を紐解くと、そこにはエンジニアにとっても合理的で、美しいアーキテクチャが存在します。 結論から言うと、アクセシビリティ対応とは「ユーザーのための特別なUIをゼロから作る」ことではありません。 「ユーザーが利用している環境(OSやブラウザなど)」と「アプリケーション」が正しくデータを連携し、歩み寄ることで初めて成立する仕組み なのです。 今回は、アクセシビリティのはじめの一歩として「環境とアプリの握手」について解説します。 ユーザーを支える強力な武器:「環境」の支援技術 アクセシビリティを考える上で大前提となるのが、デジタル環境へのアクセスを支える仕組みの大半は「環境側」にすでに用意されている、ということです。 現代のOS(iOS、Android、Windows、macOS)やWebブラウザには、ユーザーの多様な特性(視覚、聴覚、運動機能、認知など)を補助する高度な機能が標準搭載されています。これらは大きく2つのレイヤーに分けられます。 ユーザーエージェント(ブラウザなど): ズーム、文字サイズへの対応、キーボード操作など、表示や操作を調整する機能。 支援技術(Assistive Technology): スクリーンリーダー(VoiceOver / TalkBack 等)、スイッチコントロールなど、OSのAPIを通じてデバイスの操作を直接的に補助する機能。 ユーザーは自身の状況に合わせて、これらの機能をONにしたり設定を調整したりして、Webにアクセスしています。つまり、ユーザーから差し出された「握手のための手」は、すでに環境側に用意されているのです。 ただし、アクセシビリティのすべてを環境側が自動で担ってくれるわけではありません。アプリケーション自身も、十分なコントラストの確保、フォーカス順序の管理、適切な見出し構造などを責任を持って設計し、環境側と正しく連動する必要があります。 アプリケーション側の役割:情報のリレーと握手 ユーザーがスクリーンリーダーをONにしていれば、どんなWebサイトでも完璧に音声で操作できるのでしょうか? 答えは「No」です。 どんなに環境側(ブラウザや支援技術)が優秀でも、アプリケーション側が「今、画面に何が表示されていて、どのボタンが押せるのか」という意味情報(セマンティクス)を伝えていなければ、支援技術は正しく機能しません。 Webの世界では、書いたコードは以下のように情報を伝達してユーザーに届きます。 1. Webアプリ (HTML / CSS / JavaScript)       ↓ 2. ブラウザ (ユーザーエージェント) ※DOMツリーと同時に「アクセシビリティツリー」を構築       ↓ 3. OSのアクセシビリティAPI       ↓ 4. 支援技術 (スクリーンリーダーなど)       ↓ 5. ユーザー ブラウザは画面を描画するDOMツリーとは別に、支援技術向けの裏側のデータ構造である「アクセシビリティツリー」を生成します。(※Chrome DevToolsの「Accessibility(ユーザー補助)」タブなどで実際に確認することができます)。 HTMLは、このアクセシビリティツリーを正しく構築するための「指示書」なのです。このリレーを途切れさせず、環境と正しく接続(握手)することこそが、エンジニアの役割です。 Microsoftサイトのアクセシビリティツリー Webフロントエンドにおける「歩み寄り」の具体例 具体的に「環境と握手する」とはどういうことか、実務でやりがちなアンチパターンと改善例を見てみましょう。 1. 適切なHTMLタグを使う(セマンティクスの伝達) NG: <div onClick={submit}>送信</div> 視覚的にはボタンに見えても、ブラウザには「buttonとしての役割(Role)」が伝わりません。その結果、スクリーンリーダーでボタンとして認識されないだけでなく、Tabキーによる「フォーカス移動」や、Enter/Spaceキーによる「標準的なキーボード操作」が不可能になります。 OK: <button onClick={submit}>送信</button> <button>タグを使うことで、ブラウザが持つ標準的なセマンティクスとキーボード操作モデルを利用でき、環境との握手がスムーズに成立します。 2. フォームの入力欄にラベルを紐付ける(関係性の伝達) NG: <span>お名前:</span><input type="text" /> 視覚的には「名前の入力欄」ですが、支援技術が入力欄にフォーカスした際、単に「テキスト、編集テキスト」としか読み上げられず、何を入力すべきか分かりません。 OK: <label for="name">お名前:</label><input type="text" id="name" /> <label> の for 属性と <input> の id を紐付けることで「これはお名前の入力欄だ」と明確に伝わります。さらに、ラベルの文字をクリックしても入力欄にフォーカスが当たるようになり、マウスユーザーの操作性も向上します。 3. アイコンボタンに意味を持たせる(ただしARIAは計画的に) NG: <button><img src="search-icon.png" /></button> 支援技術は何のボタンか判定できません。 OK: <button aria-label="検索"><img src="search-icon.png" alt="" /></button> aria-label などを付与することで、情報を補うことができます。 【注意】 ただし、W3Cには「ARIAの第一原則(The First Rule of ARIA Use)」というものがあります。それは「標準HTMLで表現できる場合は、まず標準HTMLを使う」というルールです。ARIAは万能薬ではなく、誤用すると支援技術を混乱させるため、あくまでHTMLの補助として利用しましょう。 4. フォーカスを見えるようにする(キーボード操作の担保) NG: *:focus { outline: none; } デザイン上の理由でフォーカスリング(枠線)を消してしまうと、マウスを使わずキーボードで操作しているユーザーは「今、画面のどこにいるのか」が全く分からなくなります。 OK: :focus-visible 擬似クラスなどを活用し、キーボード操作時のみフォーカスリングを表示するようスタイリングする。環境からのアクセス経路を視覚的に遮断しないことが重要です。 「環境」と「アプリ」の機能がオーバーラップする領域 ここで一つ、実践的な観点を付け加えます。OSやブラウザの標準機能と、アプリケーションが独自に提供する機能は、 役割がオーバーラップ(重複)している領域 が存在します。 例えば、自治体のサイトなどでよく見かける「文字サイズ変更(大・中・小)」のボタンです。 文字の拡大は、本来ブラウザのズーム機能やOSの設定で行えます。しかし、すべてのユーザーが環境の設定方法に習熟しているわけではないため、アプリ側があえて独自のUIを提供し、環境側の機能を「場合によっては補完」しているのです。 ここで誤解してはいけないのは、「アプリ側で独自にボタンを作ったから、環境側の設定は無視していい」というわけではないことです。独自の文字拡大ボタンを作ったからといって、ブラウザの標準ズーム機能を使った時にレイアウトが崩れてしまっては本末転倒です。 重要なのは、アクセシビリティ機能を追加すること以上に、既存の環境の機能を「壊さない」こと。ユーザーがブラウザの標準ズーム機能を使ったとしても、レイアウトが破綻せずコンテンツが利用できるような、Web本来の堅牢でレスポンシブな設計が求められます。 まとめ:情報を「標準的な形」で公開しよう アクセシビリティ対応の核心は、「アプリケーションが持つ情報を、OSや支援技術が理解できる標準的な形で公開すること」に尽きます。 ユーザーが伸ばした手(支援技術やブラウザの設定)に対して、アプリケーション側からもしっかりと手を握り返す(データを正しく渡す)。これが、エンジニアにおけるアクセシビリティ対応の真髄です。 明日からの開発で、ぜひ以下の「最初の一歩」を試してみてください。 キーボードだけで操作してみる: マウスを置き、Tab キーと Enter / Space キーだけで、自分が作ったアプリの主要な機能が使えるか確認する。 ツールで監査してみる: ChromeのLighthouseや、axe DevToolsなどのブラウザ拡張機能を使って、自分のローカル環境のコードをチェックしてみる。 「環境」と「アプリ」のデータ連携が結実したとき、書いたコードはより強固で、本当に多くの人に届くソフトウェアになります。ぜひ「ブラウザやOSとの対話」を意識した実装を盛り込んでみてください。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post アクセシビリティは「環境」と「アプリ」の握手である 〜Webの構造から紐解くエンジニアの役割〜 first appeared on SIOS Tech Lab .
概要 前回の記事「 SSL/TLS証明書の有効期限短縮に備えて脱・手動更新② 」では、DNS-01チャレンジと、そのDNS認証を自動化するために利用するacme-dnsについて説明しました。 また、Certbotとacme-dnsを同じサーバーに導入し、acme-dnsがDNS-01チャレンジ用のDNSサーバーおよびWeb APIサーバーとして動作するところまで構築しました。 ここまでで、 Certbot acme-dns の2つが利用できる状態になっています。 しかし、この2つを導入しただけでは、CertbotがDNS-01チャレンジで使用する値をacme-dnsへ自動的に登録することはできません。 そこで今回は、Certbotとacme-dnsを連携させるための 認証スクリプト を用意します。 あわせて、認証スクリプトを利用するための前準備として、 acme-dnsへの利用登録 認証情報の保存 メインDNSへのCNAME登録 CNAMEのDNS反映確認 認証スクリプトが参照する認証情報ファイルの作成 まで行います。 なぜ認証スクリプトが必要なのか CertbotはACMEプロトコルを利用して、認証局(CA)が提供するACMEサーバーへ証明書の発行を要求します。 本記事では、以降、認証局(CA)が提供するACMEサーバーを簡略化して「認証局」と表記します。 DNS-01チャレンジを利用する場合、認証局は、証明書を取得しようとしているドメインを本当に管理していることをDNSを使って確認します。 Certbotは認証局から受け取ったチャレンジ情報をもとに、DNS-01で使用する検証値を用意します。 この検証値をDNSのTXTレコードとして公開し、認証局がDNSを問い合わせて正しい値を確認できれば、DNS-01チャレンジは成功します。 前回構築したacme-dnsには、DNS-01チャレンジ用のTXTレコードを更新するためのWeb APIがあります。 一方、Certbotは今回構築したacme-dnsのAPI認証情報をそのままでは知りません。 そのため、 Certbotから認証対象のFQDNとDNS-01の検証値を受け取り、acme-dnsの認証情報を使用してTXTレコードを更新する処理 が必要になります。 この処理を担当するのが、今回用意する 認証スクリプト です。 今回使用するacme-dns-auth.py 今回は、 acme-dns-auth.py を認証スクリプトとして利用します。 acme-dns-auth.py は、acme-dnsの開発者である joohoi氏がGitHubで公開している 、Certbotとacme-dnsを連携させるための認証Hookスクリプトです。 acme-dns本体もjoohoi氏が開発したプロジェクトで、このスクリプトはacme-dnsのWeb APIをCertbotから利用するためのサンプル実装として公開されています。公開元でも「Certbot client hook for acme-dns」と説明されています。 このスクリプトは、Certbotから認証対象のFQDNとDNS-01の検証値を受け取り、acme-dnsの /update APIを利用してTXTレコードを更新します。 それぞれの役割を整理すると、次のようになります。 要素 役割 Certbot ACMEプロトコルを利用して認証局と通信し、証明書の取得・更新を行う 認証局 DNS-01チャレンジによってドメインの管理権限を確認し、証明書を発行する acme-dns DNS-01チャレンジ用のTXTレコードを管理し、DNS問い合わせに回答する acme-dns-auth.py CertbotからFQDNと検証値を受け取り、acme-dnsのAPIを利用してTXTレコードを更新する 前回の記事では全体像を分かりやすくするため、「Certbotがacme-dnsのAPIを利用する」と簡略化して説明しました。 実際には、 Certbotから呼び出された acme-dns-auth.py がacme-dnsのAPIへアクセスします。 今回はacme-dnsへの登録を事前に行う 公開されている acme-dns-auth.py には、対象FQDNの認証情報が保存されていない場合、自動的にacme-dnsへ登録する機能があります。 一方、今回の構成では、 Certbotによる証明書取得を実行する前に、acme-dnsへの登録とCNAME設定を済ませておく 方式とします。 事前準備は次の流れになります。 acme-dnsの/register APIを実行 ↓ 認証情報と専用FQDNを保存 ↓ メインDNSへCNAMEを登録 ↓ CNAMEがDNSへ反映されたことを確認 ↓ 認証情報をacmedns.jsonへ登録 ↓ acme-dns-auth.pyを配置・設定 ↓ Certbotを実行 これにより、Certbotを実行する時点で、DNS-01チャレンジに必要なacme-dns側の初期設定を完了させておきます。 acme-dnsへ登録して認証情報を保存する acme-dnsには、利用登録を行うための /register APIがあります。 /register を実行すると、TXTレコードの更新に使用する認証情報と、その登録専用のサブドメインが払い出されます。 今回は、証明書を取得するFQDNを次のようにします。 www.example.org まず、認証情報を保存するディレクトリを作成します。 sudo install -d -m 700 -o root -g root /etc/letsencrypt/acme-dns 続いて、acme-dnsの /register APIを実行します。 返されたJSONは、そのままファイルへ保存します。 curl -sS -X POST http://127.0.0.1:8080/register \ -H 'Content-Type: application/json' \ -d '{"allowfrom":["127.0.0.1/32"]}' \ | sudo tee /etc/letsencrypt/acme-dns/www.example.org.json >/dev/null 認証情報が含まれるため、アクセス権を制限します。 sudo chmod 600 /etc/letsencrypt/acme-dns/www.example.org.json ここで重要なのは、 同じ登録のために /register を複数回実行しないこと です。 /register を実行すると、その都度、新しいacme-dnsアカウントと専用サブドメインが発行されます。 そのため、今回の手順では /register を1回だけ実行し、そのレスポンスをその場で保存します。 acme-dnsは、専用サブドメインと限定されたAPI資格情報を利用してDNS-01用TXTレコードのみを更新できるようにする仕組みです。 保存される認証情報 /register のレスポンスには、主に次の情報が含まれます。 項目 内容 username /update APIの認証に使用するユーザー情報 password /update APIの認証に使用する認証情報 subdomain 今回の登録に割り当てられたacme-dns内のサブドメイン fulldomain メインDNSのCNAME参照先として使用するFQDN allowfrom /update APIの利用を許可する送信元ネットワーク このうち username 、 password 、 subdomain は、後ほど acme-dns-auth.py がTXTレコードを更新するときに使用します。 fulldomain は、メインDNSへ設定するCNAMEの参照先として使用します。 CNAMEの参照先を確認する 続いて、実際に保存したJSONから fulldomain を確認します。 jq がインストールされていない場合は、事前にインストールします。 sudo dnf install -y jq fulldomain を確認します。 sudo jq -r '.fulldomain' /etc/letsencrypt/acme-dns/www.example.org.json 例えば、次のように表示されます。 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org この値が、今回の /register によって実際に払い出されたacme-dns側のFQDNです。 メインDNSへCNAMEを登録する 証明書を取得するFQDNが、 www.example.org で、先ほど確認した fulldomain が、 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org の場合、メインDNSには次のCNAMEを登録します。 _acme-challenge.www.example.org. CNAME xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org. これによって、認証局が、 _acme-challenge.www.example.org を問い合わせた場合、CNAMEを経由してacme-dnsが管理するFQDNへ到達できるようになります。 認証局 ↓ _acme-challenge.www.example.org ↓ CNAME xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org ↓ TXT DNS-01の検証値 acme-dnsは、既存DNSの _acme-challenge を専用サブドメインへCNAMEで向け、その参照先のTXTレコードだけをAPIから更新する仕組みです。 このCNAMEは基本的に一度設定すれば、その後の証明書更新でも同じ設定を利用できます。 証明書更新のたびに変更されるのは、CNAMEの参照先でacme-dnsが回答するTXTレコードの値です。 CNAMEがDNSへ反映されたことを確認する メインDNSへCNAMEを登録したら、 Certbotを実行する前に、DNSへ反映されていることを確認します。 次のコマンドを実行します。 dig +short _acme-challenge.www.example.org CNAME 先ほどacme-dnsから払い出された fulldomain が表示されることを確認します。 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org. このように正しいCNAME参照先が取得できれば、メインDNSからacme-dns側のFQDNを参照できる状態になっています。 CNAMEがDNSへ反映される前にCertbotを実行すると、認証局がacme-dns側のTXTレコードまで到達できず、DNS-01チャレンジに失敗する可能性があります。 そのため、 CNAMEを登録するだけでなく、実際にDNSから参照できることを確認してから次の作業へ進みます。 acme-dns-auth.pyが利用するacmedns.jsonを作成する 先ほど作成した、 /etc/letsencrypt/acme-dns/www.example.org.json には、 /register APIから返された1アカウント分の情報がそのまま保存されています。 一方、今回使用する acme-dns-auth.py は、デフォルトでは次のファイルを認証情報の保存先として参照します。 /etc/letsencrypt/acmedns.json このファイルでは、 認証対象のFQDNをキーとして、そのFQDNに対応するacme-dnsの認証情報を保持します。 例えば www.example.org の場合は、次のような構造になります。 { "www.example.org": { "username": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "fulldomain": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org", "subdomain": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "allowfrom": [ "127.0.0.1/32" ] } } 先ほど保存したJSONから、次のように作成できます。 sudo jq -n \ --arg domain "www.example.org" \ --slurpfile account /etc/letsencrypt/acme-dns/www.example.org.json \ '{($domain): $account[0]}' \ | sudo tee /etc/letsencrypt/acmedns.json >/dev/null 続いてアクセス権を設定します。 sudo chown root:root /etc/letsencrypt/acmedns.json sudo chmod 600 /etc/letsencrypt/acmedns.json 複数のFQDNを管理する場合は、既存の acmedns.json を上書きせず、それぞれのFQDNをキーとして認証情報を追加します。 acmedns.jsonへの登録を確認する 今回の構成では、Certbotを実行する前に対象FQDNの認証情報を acmedns.json へ登録しておくことが重要です。 次のコマンドで確認します。 sudo jq -e 'has("www.example.org")' /etc/letsencrypt/acmedns.json 次のように表示されれば、対象FQDNのエントリが存在しています。 true 公開されている acme-dns-auth.py には、対象FQDNの情報が acmedns.json に存在しない場合、新しいacme-dnsアカウントを自動登録する機能があります。 今回の構成ではCNAMEを事前に登録しているため、意図しない別アカウントの作成を避けるためにも、Certbot実行前に対象FQDNが正しく登録されていることを確認します。 認証スクリプトを配置する 認証情報の準備ができたら、 acme-dns-auth.py を配置します。 このスクリプトはPythonで作成されており、acme-dns APIとの通信にPythonの requests ライブラリを使用します。公開元でもCertbotとPython requestsライブラリが必要とされています。 RHEL / Rocky Linux / AlmaLinux系では、次のようにインストールします。 sudo dnf install -y python3-requests 続いて、認証スクリプトを取得します。 sudo curl -o /etc/letsencrypt/acme-dns-auth.py \ https://raw.githubusercontent.com/joohoi/acme-dns-certbot-joohoi/master/acme-dns-auth.py 実行権限を設定します。 sudo chmod 0700 /etc/letsencrypt/acme-dns-auth.py 公開元でも、 /etc/letsencrypt/acme-dns-auth.py へ配置して実行権限を付与する方法が案内されています。 認証スクリプトを設定する acme-dns-auth.py の先頭付近には、次の設定があります。 ACMEDNS_URL = "https://auth.acme-dns.io" STORAGE_PATH = "/etc/letsencrypt/acmedns.json" ALLOW_FROM = [] FORCE_REGISTER = False 今回の構成に合わせて確認します。 ACMEDNS_URL ACMEDNS_URL は、認証スクリプトがアクセスするacme-dnsのWeb APIのURLです。 前回の記事では、Certbotとacme-dnsを同一サーバーに配置し、acme-dns APIを、 127.0.0.1:8080 で待ち受ける構成としました。 そのため、次のように変更します。 ACMEDNS_URL = "http://127.0.0.1:8080" これにより、 acme-dns-auth.py からacme-dns APIへの通信は同一サーバー内部で行われます。 STORAGE_PATH STORAGE_PATH = "/etc/letsencrypt/acmedns.json" 先ほど作成した、FQDNごとのacme-dns認証情報を保持するファイルです。 acme-dns-auth.py はCertbotから認証対象FQDNを受け取ると、このファイルから対応する認証情報を取得します。 ALLOW_FROM ALLOW_FROM = [] ALLOW_FROM は、 acme-dns-auth.py 自身がacme-dnsへ新しいアカウントを登録する場合に、TXTレコードの更新を許可する送信元ネットワークを指定するための設定です。公開スクリプトでも、この値は新規登録時に使用されます。 今回の構成では、事前に curl で /register APIを実行し、その際に、 "allowfrom":["127.0.0.1/32"] を指定しています。 通常のCertbot実行時には、すでに保存済みの認証情報を利用して /update APIを呼び出すため、今回の運用ではスクリプト内の ALLOW_FROM は使用しません。 そのため、今回はデフォルトの、 ALLOW_FROM = [] のままとします。 FORCE_REGISTER FORCE_REGISTER = False FORCE_REGISTER を True にすると、保存済みの認証情報が存在していても、新しいacme-dnsアカウントを登録する動作になります。 今回は事前に /register を実行して認証情報とCNAMEを準備しているため、再登録は行いません。 そのため、 FORCE_REGISTER = False のままとします。 Certbotから認証スクリプトへ渡される情報 Certbotは認証Hookを実行するとき、DNS-01チャレンジに必要な情報を環境変数として認証スクリプトへ渡します。 現在のCertbotでは、認証対象を CERTBOT_IDENTIFIER 、検証値を CERTBOT_VALIDATION として渡します。また、後方互換性のため CERTBOT_DOMAIN にも CERTBOT_IDENTIFIER と同じ値が設定されます。 今回利用する acme-dns-auth.py は、主に次の2つを使用します。 環境変数 内容 CERTBOT_DOMAIN 認証対象のFQDN CERTBOT_VALIDATION DNS-01でTXTレコードへ設定する検証値 例えば、 CERTBOT_DOMAIN ↓ www.example.org CERTBOT_VALIDATION ↓ 今回のDNS-01検証値 という情報がCertbotからスクリプトへ渡されます。 acme-dns-auth.pyによるTXTレコード更新 Certbotから acme-dns-auth.py が呼び出されると、スクリプトは、 /etc/letsencrypt/acmedns.json から CERTBOT_DOMAIN に対応するacme-dnsの認証情報を取得します。 例えば、 CERTBOT_DOMAIN = www.example.org の場合、 www.example.org に対応する、 username password subdomain を取得します。 その後、acme-dnsの /update APIを呼び出し、Certbotから渡された CERTBOT_VALIDATION をTXTレコードへ設定します。 これによって、acme-dns側では、 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.auth.example.org. TXT "今回のDNS-01検証値" というTXTレコードが設定されます。 acme-dnsは、限定されたAPI資格情報によって専用サブドメインのTXTレコードのみを更新できるように設計されています。 認証局が、 _acme-challenge.www.example.org を問い合わせると、事前に設定したCNAMEを経由して、このTXTレコードを確認できます。 Certbotから認証スクリプトを呼び出す 事前準備が完了したら、 acme-dns-auth.py をCertbotの**認証Hook(authentication hook)**として指定します。 例えば、次のように実行します。 sudo certbot certonly \ --manual \ --preferred-challenges dns \ --manual-auth-hook /etc/letsencrypt/acme-dns-auth.py \ -d www.example.org 各オプションの役割は次のとおりです。 オプション 内容 certonly 証明書の取得のみを行う --manual Certbotのmanual pluginを使用する --preferred-challenges dns DNS-01チャレンジを使用する --manual-auth-hook DNS認証時に実行する認証スクリプトを指定する -d 証明書を取得するFQDNを指定する Certbotはmanual pluginの認証処理で --manual-auth-hook に指定されたスクリプトを実行し、認証に必要な環境変数を渡します。 今回の構成では、Certbotを実行する前に、 acme-dnsへの登録 認証情報の保存 CNAME登録 CNAMEのDNS反映確認 acmedns.json への認証情報登録 まで完了しています。 そのため、Certbotから acme-dns-auth.py が呼び出されると、保存済みの認証情報を利用してacme-dnsのTXTレコードを更新できます。 証明書取得・更新時の全体の流れ 今回の構成を、事前準備と証明書取得・更新時に分けて整理します。 事前準備 1. acme-dnsの/register APIを1回だけ実行 ↓ 2. 認証情報と専用FQDNをJSONへ保存 ↓ 3. 保存したJSONからfulldomainを確認 ↓ 4. メインDNSへCNAMEを登録 ↓ 5. CNAMEがDNSへ反映されたことを確認 ↓ 6. 認証情報をacmedns.jsonへ登録 ↓ 7. acme-dns-auth.pyを配置・設定 ↓ 8. acmedns.jsonに対象FQDNが存在することを確認 証明書取得・更新時 初期設定後は、次の処理が行われます。 1. Certbotが認証局へ証明書発行を要求 ↓ 2. 認証局からDNS-01チャレンジを要求 ↓ 3. Certbotがacme-dns-auth.pyを実行 ↓ 4. acme-dns-auth.pyがacmedns.jsonから認証情報を取得 ↓ 5. acme-dnsの/update APIを実行 ↓ 6. TXTレコードへ今回の検証値を設定 ↓ 7. 認証局がDNSを確認 ↓ 8. CNAMEを経由してacme-dns側のTXTレコードを確認 ↓ 9. DNS-01チャレンジ成功 ↓ 10. 証明書発行・更新 初期設定後は、証明書更新のたびにメインDNSのCNAMEを書き換える必要はありません。 acme-dns-auth.py がacme-dns側のTXTレコードを、その都度新しい検証値へ更新します。 今回まででできるようになったこと 今回までで、 Certbotのインストール acme-dnsのインストール・設定 acme-dnsへの利用登録 認証情報の保存 メインDNSへのCNAME登録と反映確認 acmedns.json の作成 認証スクリプトの配置・設定 まで完了しました。 これによって、 Certbot ↓ DNS-01チャレンジ ↓ acme-dns-auth.py ↓ acme-dnsの/update API ↓ TXTレコードを自動更新 ↓ 認証局がDNSを確認 ↓ DNS-01チャレンジ成功 ↓ 証明書取得・更新 という処理を自動化できます。 ただし、ここまでで自動化できたのは、 Certbotサーバー上へ新しい証明書を取得・更新するところまで です。 実際にWebサーバー、LDAPサーバー、メールサーバーなどで新しい証明書を使用するには、取得した証明書を対象サーバーへ配布し、サービスへ反映する必要があります。 Certbotには、証明書が正常に発行・更新された後に任意の処理を実行する deploy hook という仕組みがあります。 今回の構成では、このdeploy hookから証明書配布用のスクリプトを実行する想定です。 証明書配布スクリプトの内容や、取得した証明書を各サーバーへ配布してサービスへ反映する方法については、次回の記事で説明します。 次回は、 「4. 証明書配布スクリプトの用意」 について説明します。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post SSL/TLS証明書の有効期限短縮に備えて脱・手動更新③ first appeared on SIOS Tech Lab .
こんにちは、OSSよろず相談室のサポートエンジニアの神﨑です。 Linux(RHEL系)サーバーでJava環境(OpenJDK)を構築しようとした際、パッケージが2つあって迷ったことはありませんか? java-21-openjdk java-21-openjdk-devel 「どっちを選べばいいの?」「両方インストールが必要?」といった疑問に向けて、今回は2つの違いや依存関係の仕組み、実際の選び方を分かりやすく解説します! 1. 結論:違いは「実行のみ」か「開発ツールも含むか」 結論から言うと、一番の違いは 「コンパイラなどの開発用ツールが入っているかどうか」 です。 java-21-openjdk(JRE:実行環境) Javaプログラムを「動かすだけ」のパッケージ。 java-21-openjdk-devel(JDK:開発環境) 動かす機能に加えて、プログラムを「作る(コンパイルする)」ためのツールが入ったパッケージ。 「devel」は Development(開発)の略なので、開発用ツールが付いているかどうかと覚えるとスッキリしますね。 2. パッケージの具体的な中身と役割 ① java-21-openjdk いわゆる JRE(Java Runtime Environment) に相当します。Webアプリなどをサーバー上で動かすために必要な実行エンジン(JVM)や基本ライブラリが入っています。 ② java-21-openjdk-devel いわゆる JDK(Java Development Kit) に相当します。 java-21-openjdk の機能を丸ごと含んだ上で、以下のような開発用コマンドが追加されます。 javac :ソースコード(.java)を機械が読めるクラスファイルに変換するコンパイラ jar :複数のファイルを1つにまとめるパッケージングツール javadoc :ドキュメントを自動生成するツール 3. よくある疑問:「両方インストールする必要はある?」 構築手順を調べるときに「実行用と開発用、どちらも指定してインストールすべき?」と迷うポイントですが、結論から言うと java-21-openjdk-devel だけ指定すればOK です! Linuxのパッケージ管理(dnf / yum)には「依存関係」という仕組みがあります。 devel の内部仕様として java-21-openjdk が必要だと定義されているため、以下のコマンドを1回打つだけで自動的に両方が揃います。 sudo dnf install java-21-openjdk-devel 「devel を入れれば実質的に両方入る」と覚えておけば間違いありません。 4. サーバーの用途に応じたおすすめの選び方 実際の現場では、サーバーの役割によって以下のように使い分けます。 サーバーの用途 選ぶパッケージ 理由 本番環境・ステージング環境 java-21-openjdk アプリの実行しか行わないため。不要な開発ツールを入れず、シンプルかつ安全に保ちます。 開発環境・ビルドサーバー java-21-openjdk-devel ソースコードをコンパイル( javac )したり、ビルド作業を行う必要があるため。 まとめ java-21-openjdk は 「実行専用環境(JRE)」 java-21-openjdk-devel は 「開発環境(JDK)」 で、実行環境も含まれている devel をインストールすれば、依存関係で両方まとめて導入される サーバーを構築する際は、「そのサーバーでソースコードのコンパイルをするかどうか」を基準に選んでみてくださいね! 参考文献(公式ドキュメント) Red Hat公式のドキュメントも併せてご参照ください。 2.1. yum を使用した RHEL への JRE のインストール(Red Hat公式) 2.3. yum を使用した RHEL での Red Hat build of OpenJDK のインストールおよび使用(Red Hat公式) ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 【Linux】【OpenJDK】java-21-openjdk と -devel の違いとは?どっちを入れるべきか分かりやすく解説 first appeared on SIOS Tech Lab .
作業中に別のことが気になって、そっちに行っちゃったことないですか? ブログを書いていたはずなのに、いつの間にかツールを作りはじめている…僕はあります。 やらなかったらやらなかったで、終わるころには忘れています。忘れないほうは、気になって今の手がつかない。抱えたまま進めると、落ちるのは元々やってるタスクのほうです。 ここからは個人リポジトリの前提です。チームなら、とりあえずIssueに突っ込んで議題に上げておこう、という話になりますよね。 今日話すのは、湧いた別件を一旦書いて、頭からは追い出す、という話です。 すぐやったほうが早いものと、そうじゃないもの 僕は前に「 「あとで調べよう」が一生来ない人へ 」という記事で、調査は一歩目が安いんだから気になった瞬間にぶち込め、と勧めました。気になっちゃって試さずにいられない、とも書きました。すぐ動けて打っただけで終わる系の作業は「ささっ」と終わらせるに限ると思います。 今日話すのは、その逆側です。 調べるだけなら数分で本題に戻れますよね。なんなら調査をAIに依頼したらその結果だけをあとから好きなタイミングで確認をすることができます。 でも、手を動かして作り始めるものは違います。AIが書くのは速いですけど、出てきたものを見て判断するのは自分なので、見た瞬間に本題から注意が剥がれます。だから、作るときは結構なリソースを消費します。 コマンド一つで打ち終わって、出てきたものを自分で確認しなくていいものなら、 セッションをもう一個立ち上げて そっちに投げておけば済みます。今日はその手が使えない側、分けても逃がせないものの話です。 書く場所を1つ決めて、そこに落とす 書くのは1行でいいです。整形しません。後で読む人のことも考えません。場所は、毎日必ず開くファイルが1つあればよくて、日報でも作業メモでも構いません。要は後で必ず目を通す場所に置くだけです。自分が読んで分かる最低限、何を見ていて何が気になったか、それだけあれば十分です。自分で打ってもいいし、AIに書かせてもいいと思います。書くときは末尾に足すだけにして、上は読み返さないようにしています。 僕はここで、ちゃんと残さないと意味がないと思っていました。タイトルを考えて、背景を書いて、タグを付けて、優先度まで付けようとしていました。それをやるから書けなくなるんですよね。頭から追い出して、必要になったときに戻ってきやすいように置いておくだけです。 書くのと、捨てるのとで、要る理由が別なんですよね。 書くのは、忘れるからです。あとから思い出そうとしても、まず出てきません。書いておかないと、気づきごと消えます。 捨てるのは、手が出るからです。頭に残っているあいだは、気になって今のタスクが進みません。捨てるのは頭の中からだけで、書いたものはそのまま残ります。 記録ができたら頭から頑張って追い出しましょう。ハッピートリガーな皆さんはここは頑張りどころで、タスク中は忘れる努力をしましょう。んで空いたリソースで本タスクを爆速で進めましょう。 捨てた山は、あとで宝箱になります この方法の利点は、戻るきっかけを自分の記憶に依存しないことです。 戻るきっかけは、記憶じゃなくてファイルにある 「あのとき何か気になってたよな」を頭の中から引っ張り出そうとしても、まず出てきません。書いたことごと忘れているものは、思い出す側の候補にすら上がらないので。なので僕は、思い出しにいかないでファイルを検索します。日付でも、そのとき触っていたファイル名でも、雑に書いた一行の中の単語でもよくて、引っかかればその日の行に当たります。 プログラマーが作業を中断したあと、どうやって元の場所に戻っているのかを、実際の作業の記録と開発者への聞き取りの両方から調べた 研究 があります。メモを取っている人は実際に多いけれど、戻ってきてすぐ手が動くわけではない、という話です。書いてあっても、その一行に辿り着けなければ無いのと同じなんですよね。効いてくるのは書いたことそのものじゃなくて、探して引っかかるかどうかのほうです。 頭が覚えていなくても、ファイルは覚えています。次はそのファイルを起点に議論を進めてやりたかったことを進めるイメージです。 今のタスクがあるときは見ない。空いたら漁る 同じ山なのに、開くタイミングで役割が変わります。 今のタスクがあるとき、あの山はゴミ箱です。放り込むだけで、見ません。見た瞬間に手が出てしまうので。時間が空いているとき、あの山は宝箱になります。漁ると出てくるんですよね。 合図は決まった周期じゃありません。手が空いたとき、トークンが余っているとき、あとはAIに投げて返ってくるのを待っている時間みたいな、「暇だな~」ってときに輝きます。 記録は資産でもあります。でも、資産化を書く瞬間に持ち込みません。きちんとする前提で書くと重くなって、捕捉そのものが止まってしまいます。位置づけで行くと、思い付きのキャッシュファイルですね。 漁るときの作法は2手です。「やる」と決めたものが効く場所(その件に次に取りかかるときに、必ず開く場所)にもう置いてあるなら、山の側は消すだけでよくて、まだ置き場が無いなら、作ってそっちへ動かします。きちんと計画に起こすなら計画ディレクトリに移すみたいな感じですね。んで移したらきちんとファイルは削除しましょう。 ゴミなのか宝なのかは、開けてみるまで分かりません。書いた時点でどっちか決めようとするのをやめる、というのがここの答えです。 書くのは、忘れるからです。捨てるのは、手が出て足が重くなるからです。理由が別なので、片方だけやっても効きません。1行書いて、今のタスクが終わるまでは見ない。それだけです。 これだけで、タスクの速度を落とさずに行けているかも? ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post AIと作業するからこそ、頭の中はシンプルに保つ|捨てた思いつきは、あとで宝箱になる first appeared on SIOS Tech Lab .
前回 は、X の投稿を1件名指しして、正規の API 経由で取ってくる話を書きました。今回は向きが逆で、何があるか分からないものを探す側の話です。 この記事を読み終えると、X を情報源にした調べものを手元から回せるようになります。「この1週間、この話題について X で何が言われているか」を投げると、賛否が割れている論点と、その代表になっている投稿の URL が返ってくる。 新しいツールの不具合も、値上げへの反応も、使った人の詰まりどころも、記事にまとまる何日も前に X に出ます。なのに調べものを AI に任せると、そこだけ空白になるんですよね。それを埋めるのが Grok です。乗り換える話ではなくて、足りない X 検索だけを外から足す話をします。 今日話すのはこの3つです。 なぜ Grok だけ、何も繋がずに X を探せるのか 叩き方と、返ってくるもの。絞り込みは効くけど、件数だけは決められない 答えの中身を変えられるのは reasoning.effort だけ 払った額は出てきません。別の会社の規約で書けないので、X に直接お金を払う側の話は 前回 を見てください。あと、やるのは「探す」であって「全部集める」ではないです。Grok にはコーディング agent もありますが、扱うのは X 検索の部分だけです。 Grok なら、何も繋がずに X の中を探せます X を検索する道具が、最初から手元にある状態で始められます。繋ぐ作業も、X 側との別契約もいりません。 追加の契約も実装もなしに X Search が最初から載っているのは、いまのところ Grok だけ です。 なぜ、ほかの AI では返ってこないのか 前回、 x.com/robots.txt を開いて、X が AI のクローラを名指しで締め出していることを確かめました。あのときは「だから自分でスクレイピングしてはいけない」という文脈で読んでいたんですが、同じ宣言が 他の AI にも刺さっている んですよね。規約と宣言を守る側は通らない。だから返ってこない。 他の AI でも、前回紹介した公式 MCP サーバや X API の検索エンドポイントを自分で繋げば叩けます。ただしそれは、繋ぐ作業と X 側の従量課金という別の財布が要る話です。xAI は X を持っている側なので、この門の内側にいます。 紛らわしいのを一つ。GitHub Copilot でも Grok は選べますが( サポートされているモデル に Grok 4.5・4.6 が GA で載っています)、渡ってくるのはモデルだけで X Search は付いてきません。 用意するのは API キーと残高だけです ここからは grok.com の画面に貼る話じゃなくて、手元から API を叩く話です。 用意するものは2つ。xAI のコンソールで発行する API キーと、前払いの残高です。従量課金なので、残高が無いと最初の1回も通りません。ここは前回の X API と同じですね。モデルは reasoning モデルの grok-4.6 を使います。 叩いてみると、返ってくるのは散文と URL です エンドポイントは POST https://api.x.ai/v1/responses 、認証は Authorization: Bearer $XAI_API_KEY 。 tools に {"type": "x_search"} を1つ入れるだけで X 検索が有効になります。 curl https://api.x.ai/v1/responses \ -H "Authorization: Bearer $XAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "grok-4.6", "input": [{"role": "user", "content": "直近1週間の X で話題になっているものを教えて"}], "tools": [{"type": "x_search"}], "reasoning": {"effort": "low"} }' 最初につまずいたのが、返ってくるものの形です。検索結果の一覧じゃなくて、日本語の散文で書かれた回答と、引用された投稿の URL が返ってきます。骨組みだけ抜くとこうです(抜粋・省略あり)。 { "model": "grok-4.6", "reasoning": {"effort": "low"}, "output": [ {"type": "reasoning"}, {"type": "custom_tool_call", "name": "x_keyword_search", "input": "{\"query\":\"(\\\"Claude Code\\\" OR Codex) (課金 OR コスト) min_faves:20 since:2026-09-04\",\"limit\":\"10\",\"mode\":\"Latest\"}"}, {"type": "custom_tool_call", "name": "x_keyword_search", "input": "..."}, {"type": "message", "content": [{ "type": "output_text", "text": "直近1週間で盛り上がっていたのは……(日本語の散文が続く)", "annotations": [ {"type": "url_citation", "url": "https://x.com/<handle>/status/<id>", "start_index": 334, "end_index": 392} ] }]} ], "usage": { "input_tokens": 46627, "output_tokens": 1875, "output_tokens_details": {"reasoning_tokens": 1306}, "server_side_tool_usage_details": {"x_search_calls": 7} } } 生の投稿データがそのまま落ちてくると思っていたので、ここで期待とズレました。「まず生データをもらってから自分で考える」というやり方が、この道具 単体 ではできないんですよね。探すところも読むところも、モデルにお任せする形になります。原文がどうしても要るなら、引用された URL を前回作った X API 側の取得に渡すことになります。 代わりに、モデルが実際に投げた検索クエリは custom_tool_call にそのまま残ります。何回検索したかは usage.server_side_tool_usage_details.x_search_calls に入っています。次の節の観測は、この2つを読んだものです。JSON のどこを読むかの注意は、記事の末尾に付録として置いておきます。 絞り込みは効きます。件数だけは決められません ツール側には、公式に絞り込みの口があります( X Search のパラメータ 、2026-09-14 時点)。 パラメータ 何を絞るか allowed_x_handles / excluded_x_handles 投稿者のハンドル(各最大20件。両方を同時には指定できない) from_date / to_date 期間(ISO 8601) enable_image_understanding / enable_video_understanding 投稿内の画像・動画も読ませるか 僕は期間をツール側とプロンプトの両方に書きました。ツール側だけで効いているかは確かめていないんですが、プロンプト側は効いているのが見えます。「いいね20以上に絞って」「期間は 2026-09-04 以降」と日本語で書くと、モデルが組み立てた検索クエリに min_faves:20 と since:2026-09-04 が入っていました。 lang:ja は頼んでもいないのに自分で使っていたくらいです。確認できるのは モデルが演算子を正しく組み立てること までで、返ってきた投稿が本当に条件を満たしていたかは追っていません。 ところが、件数だけはどうやっても動きません。モデルが自分で決める limit は、どの条件を渡しても "10" で固定でした。「最大50件見て」と明示しても 10 のままです。代わりに検索の回数が増えます。返ってきた投稿の本文は入力トークンに積まれるので、回数が増えれば入力も増える。指示の出し方が消費に効いてくるのはこの経路です。 じゃあリクエスト側で止められないのかと REST リファレンス を見ると、口は2つあります。そして、2つとも効きません(2026-09-11 時点)。 パラメータ 公式の説明 実際どうなるか search_parameters.max_search_results 使用する検索結果の最大件数 渡すと HTTP 410 が返って、リクエストごと死ぬ。 Live search is deprecated. Please switch to the Agent Tools API というメッセージ付き max_tool_calls このレスポンスで許可するツール呼び出しの最大数 2 を渡すと HTTP 200 で通って、エラーも無い。でも実際に走った検索は6回。同じ条件で付けずに投げたときは7回だった 前者は廃止された古い仕組みの残骸で、死ぬので間違えたことはその場で分かります。引っかかるのは後者で、上限を設定したつもりのまま、設定されないで走ります。死ぬほうがまだ親切だったな、と思いました。 effort が low 、 max_turns が 6 の条件で1回試しただけなので、クライアント側のツールになら効くのかもしれません。 ただ、そもそも僕が欲しいのは件数じゃないんですよね。求めているのはトレンドを拾えることであって、正直、50件とかどうでもいい。だから触るのは、次のノブになります。 答えの中身は effort で変えられます 返ってくる答えの中身を動かせるのは、 reasoning.effort だけでした。 low か medium か high を渡します。 既定は high なので、指定しないと一番重い設定で走ります(レスポンスの reasoning にこちらの設定が返ってくるので、省略して投げると high が入っているのが見えます)。 同じ問いを、 low と high だけ変えて投げてみました。投げたのはこれです。 直近1週間の X で、Claude Code / GitHub Copilot / Gemini / Codex の「コスト事故・想定外の課金」 「エージェントの暴走」「権限設計・承認フロー」について盛り上がっている投稿を、いいね20以上に絞って エンゲージメント順に調べて。話題を3〜5個のクラスタに分けて、各クラスタは次を各2行以内で書いて: ①論点(賛否が割れていればその対立も) ②代表投稿URL ③日本語圏か英語圏か。 実際に見た投稿だけに基づき、憶測は書かない。期間は 2026-09-04 以降に限る。 effort 返ってきた内容 検索回数 入力トークン 出力トークン low 話題の名前を並べるだけ。「対立は薄い」と書いて終わり 7 46,627 1,875 high 論点と証拠。賛否が割れている対立を名指しで拾い、具体的な機構(サンドボックス設定でも許可なく外に書けた、といった中身)まで書いてくる。スレッドを辿る呼び出しも走った 17 166,417 7,489 欲しかったのは後者です。話題の名前だけだと、「もう誰かが書いた話」で終わってしまうんですよね。 留保を一つだけ。これは同じ問いを1回ずつ投げただけで、対照も盲検もありません。X のタイムラインは刻々変わるので、同じ effort で2回投げても同じものは返ってきません。信じるかどうかは、自分の問いで一回試してみてほしいです。 残りの2つは、答えの中身を変えません。 走りすぎを止める保険 です。 max_turns はターン数の上限です。ただし公式が「呼び出し回数の上限ではない」と はっきり書いています 。1ターンに複数の呼び出しが並列で載るからです max_output_tokens は reasoning トークンを含めて止まるので、hard cap になるのは実質ここだけです。公式によると 既定は 128,000 最後にもう一つ。2026-09-21 の 12:00 PT から、 X Search の課金単位が変わります 。呼び出し1回あたりから、取れた投稿1件あたりへ、です。公式は「検索やスレッド取得で返った投稿は、親投稿も引用投稿も数える」と書いています。絞りたいのは件数なのに、件数を決めているのはモデルの側という噛み合わなさは、この日から効き方が変わってくるはずです。 で、これを何に使っているか 使い道は2つに寄っています。ひとつは、X にしか出ていない最新の動き。リリース直後の反応や不具合の報告みたいに、記事にまとまる前の段階のものです。もうひとつは、Web 検索では拾えない領域の調査。ブログや公式ドキュメントになったものは AI が普通に拾ってくるので、そこはもう困っていないんですよね。困るのは、まだ誰も記事にしていないのに X では話されている層です。 僕はこれを、自分が書こうとしている領域がもう誰かに埋められていないかの確認に使っています。 叩くたびに手で書くのは続かないので、自分用の skill と CLI にしました。配布はしていないので中身は省きますが、 effort と max_turns をコマンドのフラグに出した のがポイントです。撃つ前にノブを決めないと投げられない形にしてあります。 冒頭の問いに戻ると、X だけ AI が拾ってこない、という話は Grok で埋まります。ただ、埋まり方は「検索結果がそのまま返る」じゃなくて、「探してきた誰かが要約を寄越す」なんですよね。そこは最後まで変わらないところです。 最初の一歩として渡すのは一つだけです。 effort を明示して、1回投げてみてください。既定が high なので、省略するのは「一番重いのを選んだ」のと同じことになります。 付録:返ってきた JSON のどこを読むか 返ってきたものから何かを取り出すとき、どこを読むかの一覧です。 欲しいもの どこを読むか 落とし穴 回答本文 output[] の type == "message" にある content[].text message は複数に分かれることがある 。最後の一つだけ読むと本文を取り落とす 引用された投稿の URL output[].content[].annotations[] の type == "url_citation" ドキュメントは citations と呼んでいるが、 レスポンスに citations というキーは無い 実行された検索 output[] の type == "custom_tool_call" name は x_keyword_search / x_semantic_search / x_thread_fetch の3種。 input にモデルが組んだ検索クエリがそのまま読める 検索した回数 usage.server_side_tool_usage_details.x_search_calls 指示の出し方で増えるのはここ トークン usage.input_tokens / usage.output_tokens / usage.output_tokens_details.reasoning_tokens 返ってきた投稿の本文は入力トークンに積まれる 引用の件数 usage.num_sources_used 信用しない 。引用が7件あるのに 0 を返してきたことがある もう一つ、送った設定が返りに出るかどうかは、効いたかどうかとは別の話です。 送ったもの 返りに出るか 効いたかの確かめ方 reasoning / tools そのまま直下に返る 返ってきた値を読めばいい max_turns 返ってこない x_search_calls の回数で見る max_tool_calls null で返る 同上。 null で返ってくること自体は、効かなかった証拠にはならない ではまた! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post AIにX検索を外付けする|Grok APIでXの最新情報を集める first appeared on SIOS Tech Lab .
いいねしたあの投稿、結局読み返してないですよね ども!龍ちゃんです。最近、技術の話って Web じゃなくて X に集まってきてませんか。僕はけっこうそう感じていて、しかも「あとで読もう」と思ったやつほど流れていくんですよね。 最近読んだ中で印象に残っているのが、 @HayattiQ さんのこの投稿 です。AI に「X の今」を調べさせると途端に精度が落ちるのは、AI の能力ではなく情報源の性質の問題だ、という指摘で。 この投稿自体が「X の中にしかない知見」の実例になっている のが面白いんですよね。 正直に言うと、僕もいいねだけして溜め込んでいるクチです。あとで読み返すつもりではいるんですが、実際にコピペして手元に持ってくるのは気が向いたときだけ。数えてみたら月1本くらいでした。 そこで、X API を正規に契約して、Claude Code から叩ける CLI と skill を作りました。いまは記事を読んでいて「これ取っておいて」と言えば、本文が手元に落ちてくるようになっています。 今日話すのはこの4つです。 何をやったらダメなのか 許された道はいくらか 実際にどう作ったか で、何が変わったか 順番に見ていきますね。 そもそも、何をやったらダメなんだろう 「X の投稿を機械で集める」って調べると、無料の抜け道と有料の正規ルートが混ざって出てくるんですよね。ここを先に整理しておかないと、値段の話にも進めないので、きちんと公式に行って「何がダメなのか」を確定させます。ダメには2つの層があります。 機械には、はっきり「来るな」と書いてある 機械で触る前に、まず見にいくのが x.com/robots.txt です。これはクローラー向けに「何を取っていいか」を宣言するためのファイルで、ブラウザで開けば誰でも読めます。 中身はこうでした(2026-09-10 取得)。 User-agent: * Disallow: / User-agent: * はすべてのクローラが対象で、 Disallow: / はサイト全体を禁止する、という意味なんですよね。そのうえで、名指しで許可されているクローラが3つだけあります。Googlebot、Bingbot、facebookexternalhit です。それ以外は、この一行だけで門前払いということになります。 しかも Google-Extended など AI 向けのクローラ7つは、個別に名指しで拒否されています。検索エンジンには来ていいと言いながら、AI には来るなと言っている形なんですよね。 robots.txt はあくまで宣言で、鍵ではありません。でも、X が誰に来てほしくて誰に来てほしくないかは、これで十分伝わってきました。この宣言をどう受け止めるかは、次の規約の話に譲りますね。 規約が、公式 API 以外の自動収集を禁じている 出典は X Developer Guidelines (2026-09-10 取得)です。禁止行為の表に、こう並んでいます。 Non-API Automation: Browser scripting, scraping, any automation outside official API 公式 API を通らない自動アクセスは全部そこに入っていて、ブラウザ操作もスクレイピングも名指しです。帰結も同じページにはっきり書いてあります。 Non-API automation (scraping, browser automation) results in permanent suspension. つまり、アカウントの永久停止です。 正直、非公式のツールやライブラリは実際に動くんですよね。動いてしまいます。でも、動くこととやっていいことは別の話で。 これ、AI 固有の規制ではないんですよね。「AI に取らせるのがダメ」ではなく「公式 API を通らない自動収集がダメ」という話で、人が書いたスクリプトでも同じ扱いになります。どのツールが白でどれが黒かの品評はしませんし、禁止されているのは X 上のコンテンツ (投稿やプロフィール)を機械で集めることであって、開発者向けドキュメントを読みにいくことは別の話です。 もう一つ、取ったあとの話も同じ規約の隣にあります。 Developer Policy は、X のコンテンツをオフラインに保存するなら X 上の状態に合わせて更新し続けること、削除や変更があれば24時間以内に追従することを求めています。本文も画像も同じ扱いです。つまり正規ルートで取ること自体は問題なくても、 手元に溜めた瞬間に「X で消えたら消す」義務が付いてきます 。今回は本文は手元に置いているので、この義務を引き受けている側です。月に数件なので手で追えますが、溜め込む設計にするなら削除に追従する仕組みまで要る、ということは先に知っておいたほうがいいです。 なお、ここで扱うのは「取る」話だけです。出す(投稿する)側の自動化は AIチャットで話すだけ!X予約投稿を完全自動化するシステム構築術 に書きました。 じゃあ、許された道はいくらなんだろう 「X の投稿は取れない」わけじゃないんですよね。正確には「タダでは取れない」です。公式にお金を払ってAPIを使おうねって話です。 入口は2つある。どちらも同じ財布に乗る 許された道は2つあります。ひとつは公式 API を自分で叩くこと、もうひとつは公式の MCP サーバ(xmcp)を繋ぐことです。MCP を繋ぐと、AI が自分でその場から API を叩けるようになります。ただ、xmcp を繋いでも請求は結局こちらの API プランに乗ります。少なくとも僕が調べた範囲では、MCP 経由なら無料になるという枠は見つかりませんでした。繋ぎ方は公式が X MCP Server にまとめてくれているので、ここでは同じ財布に乗るということだけ触れておきます。 無料枠はもう消えていて、読み取りは1リソース $0.005 X API の料金ページ を開くと、載っているのは従量課金の説明だけで、無料で使える枠の案内はどこにも見当たりません。以前は無料枠があったので、「まずは無料で試してみましょう」と書いてある記事はもう前提が違うんですよね。読み取りは $0.005 / リソースと書いてあります。 で、実際どうやって取るの 先に済ませておくことが2つあります やることは2つで、 認証情報を1つ発行すること と、 クレジットを買っておくこと です。 ひとつ目。開発者アカウントを作ってアプリを作ると、認証情報がいくつか発行されます。API Key、API Secret、Access Token……と並ぶので身構えるんですが、 投稿を読むだけなら Bearer Token 1つで足ります 。残りはユーザーの代理で動くとき(投稿する側)に使うものです。手順は公式の Getting Access が3ステップで書いてくれています。 ふたつ目。 クレジットを先に買っておきます。 X API には無料枠が無いので、アカウントとトークンを揃えても、クレジットが入っていなければ何も取れません。Getting Access の次のページは、もう「最初のリクエストを送ろう」です。 ちなみに僕が買ったときは、クレジットの最低チャージ金額が「$5」でした。日本円だと760円ぐらいですね(2026-09-10 時点)。 「円安やべ〜」とは思いました。でも、必要なコストだと割り切ってAPIのクレジットを買っておきましょう。1件 $0.005 なので、1件だけのつもりで払っても1000件ぶんが残る計算になります。 公式 MCP があるのに、自前で CLI を作りました 理由は2つあります。 ひとつは、やりたいことがすごく狭かったこと。記事を読んでいて X のリンクを見つけたときに、本文を全文コピペするんじゃなくて、URL を渡したら手元に落ちてくる。欲しかったのはそれだけなんですよね。X に対して何でもできる口を開けておく理由がありませんでした。 もうひとつは、従量課金を AI に自由に叩かせたくなかったこと。怖かったのは、リクエスト単位で請求が立つことです。叩いた瞬間に金が減ります。それに、もし自動でクレジットが補給される設定になっていたら、残高が切れても止まらないんじゃないか、とも思いました。ここは仕様を確認していません。確認していないので、最悪のほうに倒して設計することにしました。止まらない前提で組んでおけば、実際に止まる仕様だったとしても損はしないので。 もう少し言うと、MCP は道具が束で載っている形なんですよね。繋いだ瞬間に使える口がいくつも開いて、そのどれをどう使うかは、その場のプロンプト次第になります。 使ってほしくない使い方を止めたければ、お願いするしかない。 自前の CLI にすると、そこが変わります。縛るのが言葉じゃなくて 機能の形 になるので、たとえばまとめ取りは「やらないでね」ではなく「 そもそもできない 」になるんです。 公式の MCP(xmcp)を繋いでも、叩く前に確認を挟むこと自体はできます。Claude Code は MCP 経由のツール呼び出しにもちゃんと確認ダイアログを出してくれるので、そこは正直に認めておきたいです。ただ、あのダイアログが聞いてくれるのは「叩いていいか」だけなんですよね。 実行そのものは CLI 側に制約として持たせて、すでに取得済みの投稿かどうかの確認は skill に手順として持たせています。人間がやることとしては、X のリンクを共有するだけですね。 Step 0: 叩く前に、すでに取得済みかを確認する(同じ投稿を二度取らない) Step 1: 2件以上は件数と概算費用を先に申告して合意を取る まとめて取る機能をあえて作っていないのも同じ理由です。金が減る操作は狭いところから始めるほうが安全で、狭くて困ったら後から足せても、広くしてから事故ると戻せないからです。 叩いてみると、レスポンスで2回つまずきます 僕は自作の CLI から叩いていますが、やっていることはこの curl 1本と変わらないので、そのまま打てる形で書きます。 curl -H "Authorization: Bearer $BEARER_TOKEN" \ "https://api.x.com/2/tweets/<post_id>?tweet.fields=article,created_at,public_metrics&expansions=author_id,article.cover_media,article.media_entities&media.fields=url" 先に1つだけ。 返ってきた生の JSON は必ずファイルに残してください。 叩いた時点でもう金は減っているので、捨てると同じ金を払って取り直すことになります。僕はレスポンスとヘッダーの原本を _raw/ に、消費クレジットの台帳を1行1件で残すようにしました。 ここからが本題です。いちばん時間を溶かしたのがレスポンスの読み方でした。素直に叩くと、 text には23字しか入っていなくて焦ります。本文は article.plain_text のほうに全文入っていて、実測で1,647字ありました。冒頭の抜粋じゃなくて末尾まで、です。 しかもこの article は、リクエストで tweet.fields=article を明示しないとそもそも返ってきません。指定せずに GET /2/tweets/<id> を叩くと、返ってくるのは23字のほうだけです。公式ドキュメントには article のサブフィールドの定義が無いので(2026-09-09時点)、何が返ってくるかはドキュメント側からは埋められず、実測でしか分かりませんでした。 もうひとつ、画像でも踏みました。最初に取ったとき、Article の本文中の画像は ID のまま残って、実体で返ってきたのはカバー画像1枚だけだったんですよね。原因は僕の指定漏れで、 expansions に article.media_entities を足すだけで、本文中の画像も同じ1回の includes.media に URL 付きで返ってきます。実測だと本文の画像2枚とカバー1枚の計3枚が全部入りました。通常の投稿に添付された画像も同じで、こちらは attachments.media_keys です。どちらも media.fields=url を付けないと media_key と type しか返らないので、そこだけ注意です。 返ってくる URL は pbs.twimg.com のもので、そこから先は認証なしの普通の GET で落ちます。 ?format=jpg&name=orig を付ければ原寸です。 includes 側なので追加の課金は無く、コンソールの累計も投稿ぶんしか動いていませんでした。 ただ、落とした画像ファイルを手元に置くのはやめました。最初に書いた削除追従の義務は画像にも同じに掛かるからです。持たなければ負わないので、URL だけを保存して、中身が要るときに CDN から読んで、読んだら消すようにしています。将来的には、投稿も自分の環境に生かせる内容として取り込んだら消す運用にする予定です。 結局いくら払っているのか、確かめてみる レスポンス自体は消費クレジットを返してくれません。ヘッダーまで全部見ても、課金に関係するのは rate-limit 系だけでした。ここは生ヘッダーごと _raw/ に落としてあるので、後から数え直せます。とにかく、実額はコンソールの残高でしか追えないんですよね。 ここで数え方の話をします。コンソールの残高はセント単位に丸められるので、 1回叩いただけでは単価が出ません 。$0.005 を消費しても、減り方は $0.01 に見えるんですよね。2回叩いて累計 $0.01 まで来て、ようやく料金表の $0.005 と整合します。ただし丸めが残るので、2件で言えるのは「表の値と矛盾しない」まで。幅を潰したいなら10回叩いて $0.05 を見るのが早いです。僕は2回で止めました。 分かったことは2つです。ひとつは、著者やメディアといった関連データ( includes )が 課金対象じゃない こと。取得した2件にぶら下がっていた1件と2件は、請求に乗っていませんでした。 もうひとつが、いちばん確かめたかったところで。僕が読みたい Article(長文記事)付きの投稿は、料金表に行が存在しないんですよね。Read 表12行・Write 表16行、全部見ても無い。つまり表を読んだだけじゃ、自分が払う額が分からない。それが叩いてみたら、通常の Post 読み取りと同じ $0.005 でした。拍子抜けではあるんですが、 通常のポストと同じ金額で大量の文章情報が取れるってのはうれしいですね! ここまでに出てきた金額を、いちど並べておきます。 何に いくら どこで分かるか 投稿1件の読み取り $0.005 料金表の Read の行 Article 付きの投稿 $0.005 叩いて実測(料金表に行が無い) includes (著者・メディア) $0 2件を突き合わせて確認 画像の URL( includes.media 。添付も Article 本文中も) $0 写真4枚の投稿を2回取って、累計が投稿ぶんしか動かないのを確認 クレジットの最低購入額 $5 から コンソール(料金ページに記載なし) まとめ取りの課金単位 不明 1件ずつしか叩いていない 変わったのは、「あとで読む」が消えたこと 一文で言い切ると、払った金額が何かを変えたわけじゃないんですよね。$0.005 はただの入場料です。変わったのは、自分の環境に繋いだことのほうでした。 いま X の URL を Claude Code に渡すと、「これ取っておいて」で本文が手元に落ちてくるようになっています。最初に挙げた @HayattiQ さんの投稿も、実はこの経路で手元に落ちてきたものだったりします。 正直に計算すると、月1本の作業を自動化するのに CLI と skill を書いたわけで、費用対効果で見るとゴミですね。時間が浮いた、という話でもない。消えたのは「あとでやろう」のほうです。これまでは、X の中にある情報に対して「気が向いたらコピペする」以外の触り方を持っていなかったので、取りにいく発想がそもそも無かった。それが URL を渡すだけになると、 取りにいこうと思えるようになります 。 最初の一歩として渡すのはひとつだけです。CLI を書けとは言いません。まず1件だけ、正規ルートで取ってみてください。表に載っていない自分のユースケースの単価は、そこで初めて見えてきます。 ちなみに正規のルートはもう一本あって、Grok(xAI)から X を検索させる道です。そっちは X API のクレジットとは別の勘定、別の課金体系になります。ただ、値段の付け方が 2026-09-21 に変わるので、そこも含めて 別記事にまとめました 。課金して実際に叩いています! ではまた! 付録: article のキーは、公式に定義が無いので実測を置いておきます 本文で触れたとおり、 article のサブフィールドは公式ドキュメントに定義がありません(2026-09-09 時点)。1件ぶんのレスポンスから読み取れたものを並べておきます。字数はその投稿のものです。 キー 中身 article.plain_text 本文の全文(実測 1,647 字) article.preview_text 冒頭の抜粋(実測 111 字) article.title 記事のタイトル(実測 39 字) article.entities 本文中の構造化要素。 コードブロックが code 配列で取れます article.media_entities 本文中のメディアの ID。実体は includes.media 側に入る 指定しても返ってこなかったものも2つあります。 article_title は tweet.fields の enum にありますが返らず、タイトルは article.title のほうに入ります。 note_tweet は280字を超える長文投稿のためのフィールドで、Article とは別機能なので、Article 付きの投稿では使われません。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post AIにXの投稿を取らせる正規ルート|規約と従量課金に制限をつけて繋ぐ first appeared on SIOS Tech Lab .
ども!Chrome DevTools MCP でブラウザを触らせる話を続けて書いている龍ちゃんです。 先日、 Chrome DevTools MCP でのセットアップとログインの通し方 を書きました。あそこまで終われば、あとは普段どおりブラウザを触るだけです。その続きを触っていて、ふと思ったんですよね。これ、マニュアル作りにも使えるんじゃないの?って。 今回はそこだけ切り出して試した回です。マニュアルの本文を書かせる話や、書いた本文を仕上げるところまでは別問題なので、ここでは扱いません。 マニュアルで貼る図の手間を少し軽くする 話です。 手順書のスクショ、撮った後の手間がわりと重かった 手順書やマニュアルを作るときのいつもの流れって、大体こうですよね。 手順ごとに画面を出してスクショを撮る 画像編集ソフトで枠や矢印を描き足す PNG として保存して、名前を付ける 数えると3手です。しかも手順の数だけ、これを繰り返すことになります。 複数の対象を1枚で示したいときは、ここにもう一手増えます。①②③の番号を、ひとつずつ位置を決めながら打ち込む作業です。上の3手には数えていませんが、対象の数だけ確実に増えていく作業で、地味に手間がかかっていたところでした。 今回やったのは、この3手と番号の位置決めをまるごと「この欄を①、この欄を②、このボタンを③で囲って撮って」の一言に畳めるかという話です。画面を出すところは人が普段どおり操作するので数に入れていません。そこはどちらのやり方でも同じですからね。 結論として以下のような図がプロンプトだけで作れます。渡した指示の中身はこのあと説明しますが、まずは出てきた結果からです。 入力欄2つとボタン、合わせて3箇所を一言で囲んで、番号まで振ってもらえました。 どう指示したら、枠つきの1枚が出てくるのか 例に使うのは自分で作っているアプリの画面です。Azure上に公開しているのでブラウザ経由でアクセスを行います。 指示は2段に分けています。ひとつは自分のアプリでしか通じない指定、もうひとつはどの画面でも変わらない骨組みです。 まずアプリ側の指定です。ここは読んでいる人が自分のものに差し替えるところですね。 開く画面: Azure に置いた自作アプリ(フォーム画面 / 一覧画面) 対象の呼び名: 「タイトル」「ジム」— 画面に出ている文字でそのまま指す ここから先は、どの画面に対しても同じ骨組みです。対象を枠で囲むだけの、シンプルなものです。 1. 対象のタブを決めて、前面に出す 2. ウィンドウの寸法を固定する(1400x900) 3. 対象を囲む枠を、重ねたレイヤーとして1枚描く - 対象そのものの style / outline / border には触らない - 枠は viewport 座標で描く(描いてから撮るまでにスクロールしない) 4. 撮る 5. 枠のレイヤーを消す この骨組みで一番効いているのは、 元の画面を動かさないこと です。対象の border を直接書き換えると、その分だけ幅が増えてレイアウトが動きます。撮りたいのは普段どおりの画面なので、枠を足したせいで並びが変わったら元も子もないんですよね。なので枠は、画面の上に透明なレイヤーを1枚かぶせて、そこに描いています。消すときもレイヤーごと外すだけなので、消し忘れも起きにくい作りです。 枠がうまく出ないときは、この「元の画面に触らない」が守られているかを先に見てもらうのが早いです。 なお、ここに載せたのは骨組みだけです。実際に保存したものは初版で227行あって、枠の座標や番号の置き場を詰めるところが長い部分です。これだけを貼っても、同じ絵までは出ません。 人が言うのは1行で、長い部分はぜんぶあちら側にあります。 Chrome DevTools MCP そのものの細かい仕様は、 公式ドキュメント に譲ります。 この骨組みだけで、どこまで通るか試しました。まずは入力欄が空のフォームです。 この1枚は、対話で組み立てながら撮った回です。「タイトルの欄を囲って」と指しては、出てきた枠を見て直して、を何度か繰り返しています。一言で撃てるようになったのは、このあとですね。 さらに、フォームとはまったく性質の違う画面でも試しました。一覧に並んだカードを1枚だけ囲うパターンです。 こちらで言ったのは、これだけです。 ジムのカードを囲って撮って 対話で作った指示を、追加なしで撃っただけです。入力欄でもカードでも、同じ指示で枠が出てきました。 番号や矢印の位置、どこまで指示で決められるのか 位置や見た目まで指示で決められるのか、実際に試したのがここからです。 複数の対象を1枚で示すとき、番号バッジをどこに置くかって、直感的には二択ですよね。対象の上に重ねるか、対象の外に出して引き出すか。まずはこの2つを試しました。 試しに実験を行いました。実際に投げた言葉はこうです。 フォーム: キッカーを①、タイトルを②、生成ボタンを③で囲って撮って 一覧  : オーロラを①、ブログを②、ジムを③で囲って撮って 番号の置き場(このときは指示の側で決め打ちにしていた): A 対象に重ねる(枠の左上) B 対象の外に出して引き出す(枠の外・左右は自動で反転) 言った順が、そのまま①②③になります。手順書の番号は手順そのものなので、並べ替えは機械に任せていません。 重ねる案は、対象の中の文字を潰しました。①のバッジが HOW TO の頭を隠して IOW TO に見えてしまいます。 外に出す案は、フォームなら読めましたが、一覧では壊れました。カードの隙間は隣どうしの共有スペースなので、番号バッジが隣のカードの枠に乗ってしまい、どちらの番号なのか読めなくなります。 どちらも、置き場を「対象に重ねる(左上)」「外に出して引き出す」と決め打ちで指示に書いていたのが敗因です。そこで指示を書き換えました。 6通りの置き方を試し、「バッジが文字を覆う面積 + 別の対象の枠やバッジに乗る面積 × 4」が 最小のものを選ぶ。0 になった時点で打ち切る。 置き場を決めずに、「どう選ぶか」だけを書いたわけですね。置き場を探す仕事のほうを機械に渡して、人は「何を避けたいか」だけを書く、という分担です。 結果、フォームでは右上、一覧では左上が選ばれました。どちらも覆った文字は0件です。さっき見てもらったフォームの絵は、この右上に置かれた番号だったんですね。同じ考え方で一覧に撃ったのがこちらです。 同じ指示のはずなのに、置き場が変わっているわけですね。とはいえ賢いのは機械側ではなくて、「どこに置くか」を決めずに「どう選ぶか」を書いた指示のほうです。置き場を固定して渡していたら、さっきの没案2枚のどちらかが出てきていました。 もちろん、同じ画面で毎回まったく同じ位置に出したいなら、置き場を指定してしまうほうが早いです。一度撮って位置が決まっているなら、それで足ります。壊れたのは、確かめないまま固定を別の画面へ持ち回ったときでした。 番号に加えて、矢印も同じ指示の中で足せました。上の発話に「③に矢印も」と一言足すだけです。 更新のたびに、同じ絵を撮り直せるのか 指示は最初から書き上げたわけではなくて、チャットで一緒に試しながら詰めていったんですよね。狙った枠や番号が出るまでやり取りを重ねて、固まったところでそのプロンプトを書き出して保存しておいた、という順番です。Chrome も Claude Code のセッションも立ち上げ直してから、保存したその指示をもう一度撃ってみました。出てきたのは同じ絵です。番号と矢印まで入れた絵も、当て直して撮り直せば同じものが出てきます。 ただ、機械で確かめられるのは、画面上に文字として存在しているものだけです。背景画像に焼き込まれた文字までは判定できないので、番号がそこに乗っていないかは、最後は自分の目で見て確認しています。 機械が見えている範囲と見えていない範囲を、図にするとこんな境目になります。AI側に画像を読み込ませて判断することも可能ではあるんですが、違和感を見つけるのは人間のほうが早いので人間をループに組み込んだほうが早いです。 もちろん、画面のほうが変わったら指示も直すことになります。欄が増えたり並びが変わったりすれば、囲む対象を言い直すわけですね。ここはマニュアルを更新するときに本文を直すのと同じで、 直す対象に指示が1つ加わる という話です。 で、自分の仕事のどこで使えるのか まとめると、手順書用のスクショで一番手間だった「撮った後に枠と番号を入れ直す」ところが、プロンプトに畳まりました。冒頭で「マニュアル作りにも使えるんじゃないの」って思ったところ、番号や矢印まで含めて使えるところまでは確認できた、という結果です。 割り切ったところも書いておきます。押した瞬間の画面はこの撮り方では取れません。ボタンを押した直後に出る完了表示みたいな、寿命の短い表示は間に合いません。逆に手順書が欲しい絵って「これから押す場所」なので、押す前に撃つ形とは相性がいいです。 なので、そういった瞬間的な表示とかに関しては「Playwright」か「画面録画」で切り出すのが楽かなって思います。 あと、配れるコマンドの形にはしていません。使うなら、上の骨組みを下敷きに自分の対象サイト向けの指示を書いて、狙った絵が出るまで詰めて、サイトごとに専用のプロンプトファイルとして保存しておく形になります。僕がやったのもその順番でした。 まずは自分がよく撮る画面1つで、「この欄を①で囲って撮って」って言ってみるところから試してみてください。 ではまた! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Chrome DevTools MCPで学ぶ、手順書用スクショの枠・番号自動化 first appeared on SIOS Tech Lab .
大御所のアイコンライブラリ Font Awesome の アニメーション機能 は、CSSのクラスを追加するだけで動きを与えられ、お手軽です。動きのオプションも指定が可能です。 このアニメーション機能をGoogleの Material Symbols & Icons に移植してみたいと思い、やってみましたので共有します。 公開されているMaterial Symbols & Iconsと、 Font Awesomeのコード(GitHub) を一部拝借しました。 アニメーションのCSSを取り込む まず、Google Fonts(ウェブフォント)の読み込みをlinkタグで記述します。今回はFill(塗りつぶし)のスタイルとしました。 そして、アニメーションのCSSを収める「style.css」を呼び出すlinkタグも記述します。 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,1,0" /> <link rel="stylesheet" href="style.css"> Font AwesomeのCSSからアニメーションの動き部分を取り出してstyle.cssとして保存します。 先頭部分には、各アイコンのサイズなどのスタイルを共通設定として指定します。 /* * 共通設定 */ .material-symbols-outlined[class*="fa-"] { display: inline-block; line-height: 1; font-size: 52px; padding: 16px; color:oklch(37.2% 0.044 257.287); } /* * Font Awesomeアニメーション */ .fa-beat { animation-name: fa-beat; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-bounce { animation-name: fa-bounce; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); } .fa-fade { animation-name: fa-fade; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-beat-fade { animation-name: fa-beat-fade; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-flip { animation-name: fa-flip; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1.5s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-flip-360 { animation-name: fa-flip-360; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-shake { animation-name: fa-shake; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.75s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-spin { animation-name: fa-spin; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 2s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-spin-reverse { --fa-animation-direction: reverse; } .fa-pulse, .fa-spin-pulse { animation-name: fa-spin; animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, steps(8)); } .fa-spin-snap { animation-name: fa-spin-snap; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 3s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-spin-snap-4 { animation-name: fa-spin-snap-4; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 2.4s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-spin-snap-8 { animation-name: fa-spin-snap-8; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 4s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-buzz { animation-name: fa-buzz; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.6s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-wag { animation-name: fa-wag; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.9s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-out); transform-origin: bottom center; } .fa-float { animation-name: fa-float; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 3s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); will-change: transform; } .fa-swing { animation-name: fa-swing; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1.2s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-out); transform-origin: top center; } .fa-jello { animation-name: fa-jello; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.9s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-out); } @media (prefers-reduced-motion: reduce) { .fa-beat, .fa-bounce, .fa-fade, .fa-beat-fade, .fa-flip, .fa-flip-360, .fa-pulse, .fa-shake, .fa-spin, .fa-spin-pulse, .fa-buzz, .fa-float, .fa-jello, .fa-spin-snap, .fa-spin-snap-4, .fa-spin-snap-8, .fa-swing, .fa-wag { animation: none !important; transition: none !important; } } @keyframes fa-beat { 0% { transform: scale(1); } 25% { transform: scale(calc(1.25 * var(--fa-beat-scale, 1.25))); } 45% { transform: scale(calc(1.22 * var(--fa-beat-scale, 1.22))); } 65% { transform: scale(calc(1.25 * var(--fa-beat-scale, 1.25))); } 90% { transform: scale(1); } } @keyframes fa-bounce { 0% { transform: scale(1, 1) translateY(0); animation-timing-function: var(--fa-animation-timing); } 14% { transform: scale(var(--fa-bounce-start-scale-x, 1.06), var(--fa-bounce-start-scale-y, 0.94)) translateY(var(--fa-bounce-anticipation, 3px)); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 32% { transform: scale(var(--fa-bounce-jump-scale-x, 0.94), var(--fa-bounce-jump-scale-y, 1.12)) translateY(calc(-1 * var(--fa-bounce-height, 0.5em))); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 52% { transform: scale(1, 1) translateY(calc(-1 * var(--fa-bounce-height, 0.5em) * 1.1)); animation-timing-function: cubic-bezier(0.5, 0, 1, 0.5); } 70% { transform: scale(var(--fa-bounce-land-scale-x, 1.06), var(--fa-bounce-land-scale-y, 0.92)) translateY(0); animation-timing-function: cubic-bezier(0.33, 0.33, 0.66, 1); } 85% { transform: scale(0.98, 1.04) translateY(calc(-2px * var(--fa-bounce-rebound, 1))); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: scale(1, 1) translateY(0); } } @keyframes fa-fade { 0% { opacity: 1; transform: scale(1); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 40% { opacity: var(--fa-fade-opacity, 0.4); transform: scale(0.98); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 100% { opacity: 1; transform: scale(1); } } @keyframes fa-beat-fade { 0% { opacity: var(--fa-beat-fade-opacity, 0.4); transform: scale(1); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 25% { opacity: calc(var(--fa-beat-fade-opacity, 0.4) + 0.4); transform: scale(var(--fa-beat-fade-scale, 1.28)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 45% { opacity: 1; transform: scale(var(--fa-beat-fade-scale, 1.25)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 65% { opacity: calc(var(--fa-beat-fade-opacity, 0.4) + 0.4); transform: scale(var(--fa-beat-fade-scale, 1.28)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 100% { opacity: var(--fa-beat-fade-opacity, 0.4); transform: scale(1); } } @keyframes fa-flip { 0% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 8% { transform: perspective(2em) scale(var(--fa-flip-anticipation-scale, 0.95)) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 35% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * 0.6)); animation-timing-function: linear; } 65% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * 0.5)); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 92% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * var(--fa-flip-overshoot, 1.04))); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -360deg)); } } @keyframes fa-flip-360 { 0% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 8% { transform: perspective(2em) scale(var(--fa-flip-anticipation-scale, 0.95)) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 50% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * 0.6)); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 80% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * var(--fa-flip-overshoot, 1.04))); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -360deg)); } } @keyframes fa-shake { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1); } 8% { transform: rotate(35deg) translateX(1px); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 20% { transform: rotate(-22deg) translateX(-1px); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 35% { transform: rotate(15deg) translateX(1px); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 50% { transform: rotate(-9deg); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 65% { transform: rotate(5deg); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 78% { transform: rotate(-3deg); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 90% { transform: rotate(1deg); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 100% { transform: rotate(0deg); } } @keyframes fa-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @keyframes fa-spin-snap { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 12% { transform: rotate(60deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 16.67% { transform: rotate(60deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 28.67% { transform: rotate(120deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 33.33% { transform: rotate(120deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 45.33% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 62% { transform: rotate(240deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 66.67% { transform: rotate(240deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 78.67% { transform: rotate(300deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 83.33% { transform: rotate(300deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 95.33% { transform: rotate(360deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 100% { transform: rotate(360deg); } } @keyframes fa-spin-snap-4 { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 15% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 25% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 40% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 65% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 75% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 90% { transform: rotate(360deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 100% { transform: rotate(360deg); } } @keyframes fa-spin-snap-8 { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 9% { transform: rotate(45deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 12.5% { transform: rotate(45deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 21.5% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 25% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 34% { transform: rotate(135deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 37.5% { transform: rotate(135deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 46.5% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 59% { transform: rotate(225deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 62.5% { transform: rotate(225deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 71.5% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 75% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 84% { transform: rotate(315deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 87.5% { transform: rotate(315deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 96.5% { transform: rotate(360deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 100% { transform: rotate(360deg); } } @keyframes fa-buzz { 0% { transform: translateX(0) rotate(0deg); animation-timing-function: cubic-bezier(0.1, 0, 0.9, 1); } 5% { transform: translateX(var(--fa-buzz-distance, 4px)) rotate(0.5deg); } 10% { transform: translateX(calc(-1 * var(--fa-buzz-distance, 4px))) rotate(-0.5deg); } 15% { transform: translateX(var(--fa-buzz-distance, 4px)) rotate(0.3deg); } 20% { transform: translateX(calc(-1 * var(--fa-buzz-distance, 4px))) rotate(-0.3deg); } 25% { transform: translateX(calc(var(--fa-buzz-distance, 4px) * 0.7)) rotate(0.2deg); } 30% { transform: translateX(calc(-1 * var(--fa-buzz-distance, 4px) * 0.7)) rotate(-0.2deg); } 35% { transform: translateX(calc(var(--fa-buzz-distance, 4px) * 0.4)) rotate(0.1deg); } 40% { transform: translateX(0) rotate(0deg); } 100% { transform: translateX(0) rotate(0deg); } } @keyframes fa-wag { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 1); } 12% { transform: rotate(var(--fa-wag-angle, 12deg)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 24% { transform: rotate(2deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 1); } 36% { transform: rotate(calc(var(--fa-wag-angle, 12deg) * 0.85)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 48% { transform: rotate(1deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 1); } 58% { transform: rotate(calc(var(--fa-wag-angle, 12deg) * 0.6)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 68% { transform: rotate(0deg); } 100% { transform: rotate(0deg); } } @keyframes fa-float { 0% { transform: translateY(0) translateX(0) rotate(0deg) scale(var(--fa-float-squash-x, 1.02), var(--fa-float-squash-y, 0.98)); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 15% { transform: translateY(calc(-0.4 * var(--fa-float-height, 6px))) translateX(var(--fa-float-drift, 1px)) rotate(var(--fa-float-tilt, 1deg)) scale(1, 1); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 35% { transform: translateY(calc(-1 * var(--fa-float-height, 6px))) translateX(0) rotate(0deg) scale(var(--fa-float-stretch-x, 0.98), var(--fa-float-stretch-y, 1.03)); animation-timing-function: cubic-bezier(0.5, 0, 0.5, 0); } 50% { transform: translateY(calc(-0.92 * var(--fa-float-height, 6px))) translateX(calc(-0.5 * var(--fa-float-drift, 1px))) rotate(calc(-0.5 * var(--fa-float-tilt, 1deg))) scale(0.995, 1.01); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 70% { transform: translateY(calc(-0.3 * var(--fa-float-height, 6px))) translateX(calc(-1 * var(--fa-float-drift, 1px))) rotate(calc(-1 * var(--fa-float-tilt, 1deg))) scale(1, 1); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 90% { transform: translateY(calc(0.05 * var(--fa-float-height, 6px))) translateX(0) rotate(0deg) scale(var(--fa-float-squash-x, 1.02), var(--fa-float-squash-y, 0.98)); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: translateY(0) translateX(0) rotate(0deg) scale(var(--fa-float-squash-x, 1.02), var(--fa-float-squash-y, 0.98)); } } @keyframes fa-swing { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1); } 8% { transform: rotate(var(--fa-swing-angle, 22deg)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 18% { transform: rotate(calc(-1 * var(--fa-swing-angle, 22deg) * 0.85)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 28% { transform: rotate(calc(var(--fa-swing-angle, 22deg) * 0.65)); animation-timing-function: cubic-bezier(0.35, 0, 0.65, 1); } 38% { transform: rotate(calc(-1 * var(--fa-swing-angle, 22deg) * 0.45)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 48% { transform: rotate(calc(var(--fa-swing-angle, 22deg) * 0.25)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 56% { transform: rotate(calc(-1 * var(--fa-swing-angle, 22deg) * 0.1)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 64% { transform: rotate(0deg); } 100% { transform: rotate(0deg); } } @keyframes fa-jello { 0% { transform: scale(1, 1); animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1); } 12% { transform: scale(var(--fa-jello-scale-x, 1.15), calc(2 - var(--fa-jello-scale-x, 1.15))); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 24% { transform: scale(calc(2 - var(--fa-jello-scale-y, 1.12)), var(--fa-jello-scale-y, 1.12)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 36% { transform: scale(calc(1 + (var(--fa-jello-scale-x, 1.15) - 1) * 0.5), calc(2 - (1 + (var(--fa-jello-scale-x, 1.15) - 1) * 0.5))); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 48% { transform: scale(calc(2 - (1 + (var(--fa-jello-scale-y, 1.12) - 1) * 0.3)), calc(1 + (var(--fa-jello-scale-y, 1.12) - 1) * 0.3)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 58% { transform: scale(1.02, 0.98); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 68% { transform: scale(1, 1); } 100% { transform: scale(1, 1); } } 各種アニメーションデモ Beat (鼓動) /* * 共通設定 */ .material-symbols-outlined[class*="fa-"] { display: inline-block; line-height: 1; font-size: 52px; padding: 16px; color:oklch(37.2% 0.044 257.287); } /* * 各アニメーション */ .fa-beat { animation-name: fa-beat; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-bounce { animation-name: fa-bounce; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); } .fa-fade { animation-name: fa-fade; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-beat-fade { animation-name: fa-beat-fade; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-flip { animation-name: fa-flip; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1.5s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-flip-360 { animation-name: fa-flip-360; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-shake { animation-name: fa-shake; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.75s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); } .fa-spin { animation-name: fa-spin; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 2s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-spin-reverse { --fa-animation-direction: reverse; } .fa-pulse, .fa-spin-pulse { animation-name: fa-spin; animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, steps(8)); } .fa-spin-snap { animation-name: fa-spin-snap; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 3s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-spin-snap-4 { animation-name: fa-spin-snap-4; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 2.4s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-spin-snap-8 { animation-name: fa-spin-snap-8; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 4s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-buzz { animation-name: fa-buzz; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.6s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, linear); } .fa-wag { animation-name: fa-wag; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.9s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-out); transform-origin: bottom center; } .fa-float { animation-name: fa-float; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 3s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-in-out); will-change: transform; } .fa-swing { animation-name: fa-swing; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 1.2s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-out); transform-origin: top center; } .fa-jello { animation-name: fa-jello; animation-delay: var(--fa-animation-delay, 0s); animation-direction: var(--fa-animation-direction, normal); animation-duration: var(--fa-animation-duration, 0.9s); animation-iteration-count: var(--fa-animation-iteration-count, infinite); animation-timing-function: var(--fa-animation-timing, ease-out); } @media (prefers-reduced-motion: reduce) { .fa-beat, .fa-bounce, .fa-fade, .fa-beat-fade, .fa-flip, .fa-flip-360, .fa-pulse, .fa-shake, .fa-spin, .fa-spin-pulse, .fa-buzz, .fa-float, .fa-jello, .fa-spin-snap, .fa-spin-snap-4, .fa-spin-snap-8, .fa-swing, .fa-wag { animation: none !important; transition: none !important; } } @keyframes fa-beat { 0% { transform: scale(1); } 25% { transform: scale(calc(1.25 * var(--fa-beat-scale, 1.25))); } 45% { transform: scale(calc(1.22 * var(--fa-beat-scale, 1.22))); } 65% { transform: scale(calc(1.25 * var(--fa-beat-scale, 1.25))); } 90% { transform: scale(1); } } @keyframes fa-bounce { 0% { transform: scale(1, 1) translateY(0); animation-timing-function: var(--fa-animation-timing); } 14% { transform: scale(var(--fa-bounce-start-scale-x, 1.06), var(--fa-bounce-start-scale-y, 0.94)) translateY(var(--fa-bounce-anticipation, 3px)); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 32% { transform: scale(var(--fa-bounce-jump-scale-x, 0.94), var(--fa-bounce-jump-scale-y, 1.12)) translateY(calc(-1 * var(--fa-bounce-height, 0.5em))); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 52% { transform: scale(1, 1) translateY(calc(-1 * var(--fa-bounce-height, 0.5em) * 1.1)); animation-timing-function: cubic-bezier(0.5, 0, 1, 0.5); } 70% { transform: scale(var(--fa-bounce-land-scale-x, 1.06), var(--fa-bounce-land-scale-y, 0.92)) translateY(0); animation-timing-function: cubic-bezier(0.33, 0.33, 0.66, 1); } 85% { transform: scale(0.98, 1.04) translateY(calc(-2px * var(--fa-bounce-rebound, 1))); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: scale(1, 1) translateY(0); } } @keyframes fa-fade { 0% { opacity: 1; transform: scale(1); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 40% { opacity: var(--fa-fade-opacity, 0.4); transform: scale(0.98); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 100% { opacity: 1; transform: scale(1); } } @keyframes fa-beat-fade { 0% { opacity: var(--fa-beat-fade-opacity, 0.4); transform: scale(1); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 25% { opacity: calc(var(--fa-beat-fade-opacity, 0.4) + 0.4); transform: scale(var(--fa-beat-fade-scale, 1.28)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 45% { opacity: 1; transform: scale(var(--fa-beat-fade-scale, 1.25)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 65% { opacity: calc(var(--fa-beat-fade-opacity, 0.4) + 0.4); transform: scale(var(--fa-beat-fade-scale, 1.28)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 100% { opacity: var(--fa-beat-fade-opacity, 0.4); transform: scale(1); } } @keyframes fa-flip { 0% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 8% { transform: perspective(2em) scale(var(--fa-flip-anticipation-scale, 0.95)) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 35% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * 0.6)); animation-timing-function: linear; } 65% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * 0.5)); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 92% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * var(--fa-flip-overshoot, 1.04))); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -360deg)); } } @keyframes fa-flip-360 { 0% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.4, 1); } 8% { transform: perspective(2em) scale(var(--fa-flip-anticipation-scale, 0.95)) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), 0deg); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 50% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * 0.6)); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 80% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), calc(var(--fa-flip-angle, -360deg) * var(--fa-flip-overshoot, 1.04))); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: perspective(2em) scale(1) rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -360deg)); } } @keyframes fa-shake { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1); } 8% { transform: rotate(35deg) translateX(1px); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 20% { transform: rotate(-22deg) translateX(-1px); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 35% { transform: rotate(15deg) translateX(1px); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 50% { transform: rotate(-9deg); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 65% { transform: rotate(5deg); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 78% { transform: rotate(-3deg); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 90% { transform: rotate(1deg); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 100% { transform: rotate(0deg); } } @keyframes fa-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @keyframes fa-spin-snap { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 12% { transform: rotate(60deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 16.67% { transform: rotate(60deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 28.67% { transform: rotate(120deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 33.33% { transform: rotate(120deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 45.33% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 62% { transform: rotate(240deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 66.67% { transform: rotate(240deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 78.67% { transform: rotate(300deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 83.33% { transform: rotate(300deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 95.33% { transform: rotate(360deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 100% { transform: rotate(360deg); } } @keyframes fa-spin-snap-4 { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 15% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 25% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 40% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 65% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 75% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 90% { transform: rotate(360deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 100% { transform: rotate(360deg); } } @keyframes fa-spin-snap-8 { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 9% { transform: rotate(45deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 12.5% { transform: rotate(45deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 21.5% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 25% { transform: rotate(90deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 34% { transform: rotate(135deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 37.5% { transform: rotate(135deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 46.5% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: rotate(180deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 59% { transform: rotate(225deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 62.5% { transform: rotate(225deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 71.5% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 75% { transform: rotate(270deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 84% { transform: rotate(315deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 87.5% { transform: rotate(315deg); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 96.5% { transform: rotate(360deg); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 100% { transform: rotate(360deg); } } @keyframes fa-buzz { 0% { transform: translateX(0) rotate(0deg); animation-timing-function: cubic-bezier(0.1, 0, 0.9, 1); } 5% { transform: translateX(var(--fa-buzz-distance, 4px)) rotate(0.5deg); } 10% { transform: translateX(calc(-1 * var(--fa-buzz-distance, 4px))) rotate(-0.5deg); } 15% { transform: translateX(var(--fa-buzz-distance, 4px)) rotate(0.3deg); } 20% { transform: translateX(calc(-1 * var(--fa-buzz-distance, 4px))) rotate(-0.3deg); } 25% { transform: translateX(calc(var(--fa-buzz-distance, 4px) * 0.7)) rotate(0.2deg); } 30% { transform: translateX(calc(-1 * var(--fa-buzz-distance, 4px) * 0.7)) rotate(-0.2deg); } 35% { transform: translateX(calc(var(--fa-buzz-distance, 4px) * 0.4)) rotate(0.1deg); } 40% { transform: translateX(0) rotate(0deg); } 100% { transform: translateX(0) rotate(0deg); } } @keyframes fa-wag { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 1); } 12% { transform: rotate(var(--fa-wag-angle, 12deg)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 24% { transform: rotate(2deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 1); } 36% { transform: rotate(calc(var(--fa-wag-angle, 12deg) * 0.85)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 48% { transform: rotate(1deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 1); } 58% { transform: rotate(calc(var(--fa-wag-angle, 12deg) * 0.6)); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 68% { transform: rotate(0deg); } 100% { transform: rotate(0deg); } } @keyframes fa-float { 0% { transform: translateY(0) translateX(0) rotate(0deg) scale(var(--fa-float-squash-x, 1.02), var(--fa-float-squash-y, 0.98)); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 15% { transform: translateY(calc(-0.4 * var(--fa-float-height, 6px))) translateX(var(--fa-float-drift, 1px)) rotate(var(--fa-float-tilt, 1deg)) scale(1, 1); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 35% { transform: translateY(calc(-1 * var(--fa-float-height, 6px))) translateX(0) rotate(0deg) scale(var(--fa-float-stretch-x, 0.98), var(--fa-float-stretch-y, 1.03)); animation-timing-function: cubic-bezier(0.5, 0, 0.5, 0); } 50% { transform: translateY(calc(-0.92 * var(--fa-float-height, 6px))) translateX(calc(-0.5 * var(--fa-float-drift, 1px))) rotate(calc(-0.5 * var(--fa-float-tilt, 1deg))) scale(0.995, 1.01); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); } 70% { transform: translateY(calc(-0.3 * var(--fa-float-height, 6px))) translateX(calc(-1 * var(--fa-float-drift, 1px))) rotate(calc(-1 * var(--fa-float-tilt, 1deg))) scale(1, 1); animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); } 90% { transform: translateY(calc(0.05 * var(--fa-float-height, 6px))) translateX(0) rotate(0deg) scale(var(--fa-float-squash-x, 1.02), var(--fa-float-squash-y, 0.98)); animation-timing-function: cubic-bezier(0.33, 0, 0.66, 1); } 100% { transform: translateY(0) translateX(0) rotate(0deg) scale(var(--fa-float-squash-x, 1.02), var(--fa-float-squash-y, 0.98)); } } @keyframes fa-swing { 0% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1); } 8% { transform: rotate(var(--fa-swing-angle, 22deg)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 18% { transform: rotate(calc(-1 * var(--fa-swing-angle, 22deg) * 0.85)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 28% { transform: rotate(calc(var(--fa-swing-angle, 22deg) * 0.65)); animation-timing-function: cubic-bezier(0.35, 0, 0.65, 1); } 38% { transform: rotate(calc(-1 * var(--fa-swing-angle, 22deg) * 0.45)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 48% { transform: rotate(calc(var(--fa-swing-angle, 22deg) * 0.25)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 56% { transform: rotate(calc(-1 * var(--fa-swing-angle, 22deg) * 0.1)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 64% { transform: rotate(0deg); } 100% { transform: rotate(0deg); } } @keyframes fa-jello { 0% { transform: scale(1, 1); animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1); } 12% { transform: scale(var(--fa-jello-scale-x, 1.15), calc(2 - var(--fa-jello-scale-x, 1.15))); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 24% { transform: scale(calc(2 - var(--fa-jello-scale-y, 1.12)), var(--fa-jello-scale-y, 1.12)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 36% { transform: scale(calc(1 + (var(--fa-jello-scale-x, 1.15) - 1) * 0.5), calc(2 - (1 + (var(--fa-jello-scale-x, 1.15) - 1) * 0.5))); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 48% { transform: scale(calc(2 - (1 + (var(--fa-jello-scale-y, 1.12) - 1) * 0.3)), calc(1 + (var(--fa-jello-scale-y, 1.12) - 1) * 0.3)); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); } 58% { transform: scale(1.02, 0.98); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } 68% { transform: scale(1, 1); } 100% { transform: scale(1, 1); } } favorite add_circle zoom_out_map pan_zoom fingerprint bolt mode_heat <span class="material-symbols-outlined ms-beat">favorite</span> <span class="material-symbols-outlined ms-beat">add_circle</span> <span class="material-symbols-outlined ms-beat">zoom_out_map</span> <span class="material-symbols-outlined ms-beat">pan_zoom</span> <span class="material-symbols-outlined ms-beat">fingerprint</span> <span class="material-symbols-outlined ms-beat" style="--ms-beat-scale: 2.0;">bolt</span> <span class="material-symbols-outlined ms-beat" style="--ms-animation-duration: 0.5s;">mode_heat</span> Fade (フェード) warning timer motion_blur battery_1_bar diamond_shine bomb footprint <span class="material-symbols-outlined fa-fade">warning</span> <span class="material-symbols-outlined fa-fade">timer</span> <span class="material-symbols-outlined fa-fade" style="--fa-fade-opacity: 0;">motion_blur</span> <span class="material-symbols-outlined fa-fade">battery_1_bar</span> <span class="material-symbols-outlined fa-fade">diamond_shine</span> <span class="material-symbols-outlined fa-fade" style="--fa-animation-duration: 1s;">bomb</span> <span class="material-symbols-outlined fa-fade">footprint</span> Beat-Fade (鼓動、フェード) info release_alert speaker_2 trophy award_meal editor_choice celebration <span class="material-symbols-outlined fa-beat-fade">info</span> <span class="material-symbols-outlined fa-beat-fade">release_alert</span> <span class="material-symbols-outlined fa-beat-fade">speaker_2</span> <span class="material-symbols-outlined fa-beat-fade">trophy</span> <span class="material-symbols-outlined fa-beat-fade">award_meal</span> <span class="material-symbols-outlined fa-beat-fade">editor_choice</span> <span class="material-symbols-outlined fa-beat-fade" style="--fa-beat-fade-opacity: 0.1; --fa-beat-fade-scale: 2;">celebration</span> Bounce (バウンド) sports_volleyball sports_basketball mail chat cruelty_free skateboarding kitesurfing <span class="material-symbols-outlined fa-bounce">sports_volleyball</span> <span class="material-symbols-outlined fa-bounce" style="--fa-bounce-land-scale-x: 1.2;--fa-bounce-land-scale-y: .8;--fa-bounce-rebound: 5px;">sports_basketball</span> <span class="material-symbols-outlined fa-bounce" style="--fa-bounce-start-scale-x: 1;--fa-bounce-start-scale-y: 1;--fa-bounce-jump-scale-x: 1;--fa-bounce-jump-scale-y: 1;--fa-bounce-land-scale-x: 1;--fa-bounce-land-scale-y: 1;--fa-bounce-rebound: 0;">mail</span> <span class="material-symbols-outlined fa-bounce" style="--fa-bounce-start-scale-x: 1;--fa-bounce-start-scale-y: 1;--fa-bounce-jump-scale-x: 1;--fa-bounce-jump-scale-y: 1;--fa-bounce-land-scale-x: 1;--fa-bounce-land-scale-y: 1;--fa-bounce-rebound: 0;">chat</span> <span class="material-symbols-outlined fa-bounce" style="--fa-bounce-start-scale-x: 1; --fa-bounce-start-scale-y: 1; --fa-bounce-jump-scale-x: 1; --fa-bounce-jump-scale-y: 1; --fa-bounce-land-scale-x: 1; --fa-bounce-land-scale-y: 1;">cruelty_free</span> <span class="material-symbols-outlined fa-bounce" style="--fa-animation-duration: 2s;">skateboarding</span> <span class="material-symbols-outlined fa-bounce" style="--fa-animation-duration: 4s;">kitesurfing</span> Buzz (振動) mobile notifications alarm <span class="material-symbols-outlined fa-buzz">mobile</span> <span class="material-symbols-outlined fa-buzz" style="--fa-animation-duration: 2s; --fa-buzz-distance: 9px;">notifications</span> <span class="material-symbols-outlined fa-buzz">alarm</span> Flip (裏返し 180度) poker_chip docs send album cannabis award_star verified <span class="material-symbols-outlined fa-flip">poker_chip</span> <span class="material-symbols-outlined fa-flip">docs</span> <span class="material-symbols-outlined fa-flip" style="--fa-flip-x: 1; --fa-flip-y: 0;">send</span> <span class="material-symbols-outlined fa-flip" style="--fa-flip-x: 1; --fa-flip-y: 1;">album</span> <span class="material-symbols-outlined fa-flip" style="--fa-animation-duration: 3s;">cannabis</span> <span class="material-symbols-outlined fa-flip">award_star</span> <span class="material-symbols-outlined fa-flip">verified</span> Flip-360 (裏返し 360度) poker_chip docs send album cannabis award_star verified <span class="material-symbols-outlined fa-flip-360">poker_chip</span> <span class="material-symbols-outlined fa-flip-360">docs</span> <span class="material-symbols-outlined fa-flip" style="--fa-flip-x: 1; --fa-flip-y: 0;">send</span> <span class="material-symbols-outlined fa-flip-360" style="--fa-flip-x: 1; --fa-flip-y: 1;">album</span> <span class="material-symbols-outlined fa-flip-360" style="--fa-animation-duration: 3s;">cannabis</span> <span class="material-symbols-outlined fa-flip-360">award_star</span> <span class="material-symbols-outlined fa-flip-360">verified</span> Float (浮遊) cloud tooltip bubble_chart hot_tub sailing flight_takeoff <span class="material-symbols-outlined fa-float">cloud</span> <span class="material-symbols-outlined fa-float">tooltip</span> <span class="material-symbols-outlined fa-float" style="--fa-float-height: .5em;">bubble_chart</span> <span class="material-symbols-outlined fa-float">hot_tub</span> <span class="material-symbols-outlined fa-float" style="--fa-animation-duration: 2s;">sailing</span> <span class="material-symbols-outlined fa-float">flight_takeoff</span> Jello (ゼリーのような揺れ) water_drop kid_star deployed_code support <span class="material-symbols-outlined fa-jello">water_drop</span> <span class="material-symbols-outlined fa-jello">kid_star</span> <span class="material-symbols-outlined fa-jello" style="--fa-jello-scale-x: 1.5;">deployed_code</span> <span class="material-symbols-outlined fa-jello" style="--fa-animation-duration: 2s;">support</span> Shake (振る) notifications timer lock science casino <span class="material-symbols-outlined fa-shake">notifications</span> <span class="material-symbols-outlined fa-shake">timer</span> <span class="material-symbols-outlined fa-shake">lock</span> <span class="material-symbols-outlined fa-shake">science</span> <span class="material-symbols-outlined fa-shake">casino</span> Spin (回転) cycle autorenew progress_activity settings star brightness_empty avg_pace workspaces settings_backup_restore mobile_rotate change_circle <span class="material-symbols-outlined fa-spin">cycle</span> <span class="material-symbols-outlined fa-spin">autorenew</span> <span class="material-symbols-outlined fa-spin">progress_activity</span> <span class="material-symbols-outlined fa-spin-snap">settings</span> <span class="material-symbols-outlined fa-spin">star</span> <span class="material-symbols-outlined fa-spin-snap-8">brightness_empty</span> <span class="material-symbols-outlined fa-spin-pulse">avg_pace</span> <span class="material-symbols-outlined fa-spin">workspaces</span> <span class="material-symbols-outlined fa-spin fa-spin-reverse">settings_backup_restore</span> <span class="material-symbols-outlined fa-spin fa-spin-reverse">mobile_rotate</span> <span class="material-symbols-outlined fa-spin fa-spin-reverse">change_circle</span> Swing (ぶら下がり揺れ) lock_open content_paste key_vertical notifications <span class="material-symbols-outlined fa-swing">lock_open</span> <span class="material-symbols-outlined fa-swing" style="--fa-animation-duration: 2s;">content_paste</span> <span class="material-symbols-outlined fa-swing">key_vertical</span> <span class="material-symbols-outlined fa-swing" style="--fa-swing-angle: 45deg;">notifications</span> Wag (しっぽ振り) water_full local_laundry_service pets balance music_note <span class="material-symbols-outlined fa-wag">water_full</span> <span class="material-symbols-outlined fa-wag">local_laundry_service</span> <span class="material-symbols-outlined fa-wag">pets</span> <span class="material-symbols-outlined fa-wag" style="--fa-animation-duration: 2s;">balance</span> <span class="material-symbols-outlined fa-wag" style="--fa-wag-angle: 45deg;">music_note</span> 感想 実際のウェブサイトやアプリケーションに組み込む場合は、アニメーションの多用は禁物で、使いどころを見極める必要があると思いました。動き続ける要素は目障りだったり、アクセシビリティが確保されない場合があります。 装飾としてアニメーションさせる場合は停止ボタンを設けたり、ボタンの操作に対してのみ動きをつける、ループの回数を制限するなど、配慮が必要です。 読み込み中を示す動くアイコンは状況を分かりやすくしてくれますし、注意を促したり、次のアクションを導くための動きは実用的です。ここぞというところで、動くアイコンを組み込みたいと思います。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Material Symbols & IconsにFont Awesomeのアニメーションを適用してみた first appeared on SIOS Tech Lab .
サイオステクノロジーは、OSS管理ツール「SCANOSS」の 日本国内初の代理店 です。 生成AIでコードを書く比重が上がるにつれ、「動くコードは手に入ったが、それを使っていいか」を確かめる工程が抜け落ちるようになりました。テストは通る。脆弱性スキャンを回していれば、それも通る。しかし、そのコードがどこ由来なのかを見る工程は、どちらにも入っていません。 AIが生成したコードに含まれるOSSは、読んで見分けることができません 。機械的な照合で見える範囲と、機械にも見えない範囲が残ります。 この記事でわかること : 生成されたコードの出所を、読んで見分けられない理由 OSSが混ざっていた場合に、ライセンス種別ごとに何が求められるか 何を見るツールがあり、そのうちコードを見るものはどれか 機械的な照合でも判定できない範囲 このコードにOSSは入っているか 次のコードを読んでください。JavaScriptで、文字列をUTF-8のバイト列に変換する処理です。 // convert string to array (typed, when possible) exports.string2buf = function (str) { var buf, c, c2, m_pos, i, str_len = str.length, buf_len = 0; // count binary size for (m_pos = 0; m_pos < str_len; m_pos++) { c = str.charCodeAt(m_pos); if ((c & 0xfc00) === 0xd800 && (m_pos + 1 < str_len)) { c2 = str.charCodeAt(m_pos + 1); if ((c2 & 0xfc00) === 0xdc00) { c = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); m_pos++; } } buf_len += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; } // …(UTF-8バイト列への書き込みは中略)… return buf; }; このコードにOSS由来のものが混ざっているか、読んで判定できたでしょうか。 答えは、 このコード自体がOSS です。圧縮ライブラリ pako v1.0.11 の lib/utils/strings.js からの抜粋(MIT)で、後半のバイト列への書き込みは省略してあります。 pako — Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn / MIT License 見分けられなかったとしても、注意力の問題ではありません。理由は2つあります。 照合すべきOSSが多すぎる 1つ目は単純な話です。世に公開されているOSSのコードすべてと、目の前のコードを突き合わせる作業になります。人間の記憶で照合できる規模ではありません。 そして、これは「珍しく起きること」でもありません。ICSE 2025で発表された LiCoEval は、14のLLMに4,187件のコードを生成させ、既存のOSS実装と強く似ているものがどれだけ含まれるかを測っています。コード生成の性能が高い3モデル(GPT-4o / Claude 3.5 Sonnet / DeepSeek-Coder-V2)では、生成したコードのうち 0.88%〜2.01% が該当しました。14モデル全体で見ると 0.0%〜2.17% まで幅があり、最多はコード生成の性能で下位のCodestral(2.17%)、0件だったのは性能が中位のGLM-4-9B-Chatでした。なお、この測定の対象は2024年時点のモデル群です。モデルは入れ替わるので、この数値がそのまま今のモデルに当てはまるわけではありません。 数字だけ見ると小さく感じますが、 この値は下限 です。論文自身が測定の限界としてこう書いています。 Our striking similarity standard focuses on precision, potentially overlooking cases where LLMs generate code derived from open-source code but fall below our threshold (この判定基準は精度を重視しているため、OSS由来のコードを生成していても閾値を下回るケースは見落とす可能性がある) つまり、実際にはこれより多く起きている可能性があります。 出力に出所の情報が付かない 2つ目のほうが厄介です。人がOSSを持ってくるときは、由来を知っていて、多くの場合ライセンスファイルも一緒に付いてきます。生成された場合は、 何も付いてきません 。 同じLiCoEvalは、モデルが生成したコードについて、ライセンス情報を正しく示せたかも測っています。コピーレフトライセンス(次章で見るとおり、義務がもっとも重い種別)のコードで出所を正しく示せた割合は、GPT-4o・GPT-3.5 Turbo・GPT-4 Turbo・Gemini 1.5 Pro・DeepSeek-Coder-V2・Codestral のいずれも 0.0 。Claude 3.5 Sonnet の 0.4 が唯一の例外でした。 「性能の高いモデルを使えば避けられる」という話でもありません。0.0 が並んでいるモデルには、コード生成の性能で上位3モデルに入る GPT-4o と DeepSeek-Coder-V2 が含まれています。論文自身も、この種のスコアと生成性能が対応しないことに注意を促しています。 a high LICO score, particularly a score of 1 in the absence of any strikingly similar cases, is not meaningful if the model’s code generation performance is poor. Models producing erroneous or chaotic code may naturally avoid striking similarities (コード生成の性能が低いモデルでは、高いスコア(とくに似た事例が0件で満点になる場合)は意味を持たない。誤ったコードや混乱したコードを出すモデルは、そもそも強い類似を避けてしまう) つまりこの手のスコアは、性能が低いモデルほど良く見える向きに歪みます。 モデルを選び直して解決する問題ではありません 。 OSSが混ざっていたら、何をしないといけないのか 「AIが出力したコードの著作権をめぐる争いはまだ決着していないのだから、様子を見ればいい」と考えることもできます。実際、その論点に外から答えは出ていません。 Copilotの出力をめぐる集団訴訟( Doe v. GitHub )は、22件の請求のうち20件が地裁で却下され、契約違反とOSSライセンス違反の2件が係属中です。却下された著作権管理情報に関する請求は控訴され、2026年2月11日に第9巡回区で口頭弁論が行われ、判断を待っている状態です(2026年9月3日時点)。 ただし、 混ざっていた場合に何が求められるかは、AIとは関係なく、すでに明文で決まっています 。 種別 代表例 コードに取り込んだ場合に生じること permissive MIT / Apache-2.0 / BSD 著作権表示とライセンス文の 表示 。義務がゼロではありません 弱コピーレフト LGPL / MPL 表示に加えて、 そのOSS部分を改変したならソースの開示 強コピーレフト GPL / AGPL 結合した自社のコードにまで開示義務が及びうる 。AGPLはネットワーク越しに使わせる場合にも及びます 順に見ていきます。permissiveは「自由に使える」と理解されがちですが、表示の義務は残ります。表示を落とせば条件違反です。 弱コピーレフトは、そのOSS部分を改変したかどうかで変わります。改変して配布するなら、その部分のソースを開示することになります。 強コピーレフトは、影響範囲が自分のコード側に及びます。どこまでが「結合」なのかは配布形態や結合方法で変わるため、ここが最も判断の重い領域です。 ただし、同じ種別でも個別のライセンスごとに条件は違います。上の表は種別ごとに何が付いてくるかの傾向で、実際にどの義務が生じるかは、そのライセンスの条文と、結合の形や配布の方法で決まります。どのライセンスを通すかも、使う側が決めることです。 どちらも、どんなライセンスのものが混ざっているかが分かってからの話 です。 ツールは何をしてくれるのか 人間が読んで分からないのであれば、機械に照合させることになります。ただし「OSSライセンスを見るツール」とまとめられているものは、 何を見ているかがそれぞれ違います 。3つに分かれます。 以下は 何を見ている道具なのか の分類で、製品どうしの比較ではありません。冒頭に書いた立場のとおり、この分類には当社が扱う製品も入ります。 何を見ているか 道具 AI生成コードの混入 依存の宣言(manifestとlockfile) Dependency graph / dependency-review-action / Trivy 映らない ファイルの中のライセンスの記述 ScanCode Toolkit ライセンス文が残っていれば映る コード片の指紋 FossID / Black Duck / SCANOSS ここで映る 上から順に、見ている対象がコードの内側へ入っていきます。いちばん下の指紋の照合だけが、宣言にもライセンス文にも現れないコードそのものを見ます。この層は製品が限られており、公式ドキュメントで機能を確認できたのは上の3つでした [^1]。 表に載せていない道具にも触れておきます。依存しているパッケージの問題を知らせる仕組みとして広く入っているのが、GitHubの Dependabotのアラート です。 見ているのは、表のいちばん上の層です 。公式ドキュメントを確認した限り、Dependabotのアラートは脆弱性の検出に限定されていて、ライセンスを見る機能への言及がありません。 すでに何かを回しているから、ライセンスまで見えているとは限りません 。 AI生成コードの混入が問題になるのは、 表のいちばん下が扱う範囲 です。宣言されていないコードは、依存の宣言を見るツールには映りません。 [^1]: 同じ層で公式ドキュメントに記述を確認できたものとして、他に FOSSA (ファイル単位の指紋照合・確率的なマッチと明記)と Revenera Code Insight (source-code fingerprints)があります。本記事は各製品の精度・速度・価格の比較は行いません。 機械に任せれば終わりなのか ここまでで「機械に照合させればよい」という話になりますが、機械にも見えない範囲が残ります。 1つは、 手が入るほど当たらなくなる ことです。指紋の照合が強いのは、そのままの形に近いコードです。書き直されたり、別の言語へ移されたりするほど、照合の手がかりは減っていきます。これは特定の製品の性能ではなく、 指紋を照合するという方法そのものの性質 です。どこまで手が入ると外れるのかは、道具によって違います。 もう1つは、 似ていることが由来の証明にはならない ことです。LiCoEvalはこう書いています。 Text similarity alone cannot determine non-independent creation in LLM-generated code. LLMs can produce highly similar code even for unseen samples (テキストの類似度だけでは、LLMが生成したコードが独立創作でないとは判定できない。学習で見ていないサンプルに対しても、LLMは非常に似たコードを出せる) 似ているコードが出てきたとき、それが取り込みなのか、独立して書かれた結果なのかは、機械の出力だけでは決まりません。もちろん機械検出の製品が100%発見してくれるとも言えません。 ただし、限界があることは、機械に照合させない理由にはなりません。 比べる相手は「完璧な検出」ではなく、何も見ていない状態のほう です。完璧に検出できる道具が無いのはこの領域に限りませんが、それでも入れるのは、入れない場合との差が大きいからです。 分担で言うと、 機械が担うのは検出まで です。出てきたものが本当に取り込みなのかを決めるのは人の側に残ります。この分担を先に持っておくと、ツールの出力を「答え」ではなく「候補」として受け取れます。 手元で動かしてみる場合は、SCANOSS の CLI でローカルスキャンを試す手順を 別の記事 にまとめています。インストールからスキャン結果の読み方、SBOM の生成までを扱っています。 なぜこの確認が要るのか AIに書かせたコードは、スキャンにかけて判断するしかありません。 読んで見分けられない以上、出所を確かめる方法がほかに無いからです。 冒頭のコードに戻ります。あれがpako由来だと見分けられなかったのは、照合すべきOSSが多すぎるうえに、生成されたコードには出所が付いてこないからでした。人の目では、どちらも埋められません。書かれる量が増えても、埋まるようにはなりません。 そして、 確かめていないことは、混ざっていないことではありません 。混ざっていた場合に生じる義務は、すでに決まっています。スキャンしていないコードについて言えるのは「混ざっていない」ではなく、「 混ざっているかどうかを言えない 」だけです。生成の速度が上がるほど、その状態のコードが増えていきます。 機械が出せるのは候補までで、そこから先は人が判断します。それでも、候補が出てこなければ判断のしようがありません。 自分のコードに何が入っているかは、外の答えを待たなくても、確かめれば分かります。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post AI生成コードのOSSライセンス|自動スキャンが必要な理由 first appeared on SIOS Tech Lab .
セッション、こういうときどうしてますか セッションを動かしてると、こういう瞬間ってありませんか。 タスクの最中に「これもやりたい!」って思いついたとき 「なんか AI の返答が荒れたぞ!」って思ったとき 並列でセッションを回してて、頭がパンクしてるとき 作業がひと段落して、「よし、ここまではできた」ってなる。でもまだコミットは打てない段階。まぁセッションが長くなったら性能が落ちるって話はもうだいぶ有名ですよね。 このときに僕がやっているのは、AI にドキュメントを作らせることです。次のセッションへの 発注書 ですね。そこに人間の監査を入れて質を上げるというのが今回のお話です。 これ、チャットで新しい会話を始める人にも同じことは起きると思うんですが、そっちは Geminiの答えが雑になってきた人へ。会話は使い捨てでいいんです に書きました。ここでは Claude Code の話に絞ります。 セーブポイントって、もう機能でありますよね 引き継ぎのドキュメントと言われると、セーブポイントの機能がもうあるじゃないかと思う方もいると思います。たしかにその通りで、Claude Code には正式に checkpointing という機能があります。しかもこれ、プロンプトを送るたびに勝手に作られてるんですよね(100個まで保持されて、30日で消えます)。 セーブポイントっぽい操作、実は3つあります。 /resume :セッションをまたいで、中断したところを一括で復帰する /rewind :同一セッション内。地点を選んで戻せます。選べるのは5択(コードと会話/会話だけ/コードだけ/ここから先を要約/ここより前を要約) /compact :同一セッション内。会話を構造化サマリーに置き換えます。 /compact focus on 〜 で焦点を指示することもできます 実際に1ヶ月ぶんの履歴を数えてみたら、 /clear が221回でした。それに対して /rewind は0回、 /compact も0回。引き継ぎドキュメントを作ったのは18回です。(基本的に1セッションに収まるだけのタスクに切り分けられている感じですね) ただし /resume だけは、VSCode が落ちたときの復旧の際に使っています。 前提として /rewind と /compact は同一セッション内でしか使えない操作です。選べるのは範囲か焦点までなんですよね。どれを残すかを自分で決める工程がない。 /rewind は「いつ」しか選べないし、 /compact は焦点を指示できても、実際に書くのは AI です。実際にセッション内でどう解釈されたのか?といったことを確認することができないんです。なので、セッション内でAIとの間に誤解が生まれたらそれを気づくことができないって感じですね。 /resume は全部持っていきます。これはセッションをあとからAIに分析させるイメージですね。これをそのまま使うのもありなんですが、トークン効率的に極力やりたくはないですね。 /rewind はガチのセーブポイントですね。特定の起点にタグを打って、またそこから再スタートするみたいなことができます。 /compact のほうは、AI 自身が会話を圧縮するのが課題です。AI の出力が荒れているとして、荒れた人が圧縮したらその圧縮を信用していいのか?ってなりますよね。 ただ3つ並べてみると、これ全部「 過去をどう残すか 」の道具になります。地点まで戻す、要約して残す、丸ごと運ぶ。向きが全部うしろです。 発注書は逆で、何をやったかじゃなくて次に何をやるかが書いてある紙です。0回なのもたぶんそういうことで、機能が足りないんじゃなくて、僕が欲しかったものが保存じゃなかった。 AIに指示をまとめた発注書を作らせる 発注書だと思うと、わざわざドキュメントにしている理由もはっきりします。セーブデータって、人間は読まないんですよ。復元する側が読むものなので。でも発注書は、出した人が読み返して「あ、これ違う」って直すものですよね。会話の中に埋まっているうちは、自分がどう解釈されたのかを確かめられません。ドキュメントになると読み返せます。読み返せるから、AI との間にできた誤解をこっちが見つけて直せます。 そもそも、いつ渡すのか 引き継ぎを作るのは会話が重くなってからだと思われがちなんですが、人間の都合にも使えます。 物理的に中断されるとき(移動する、その日の作業を終える) 並行させたいとき(別のことを同時に走らせる) AIの出力が不安定になったとき(「ヒヤリハットなんで切り替えようか」) 長文が出されて頭が鈍ってきたとき 冒頭に挙げた3つの瞬間も、全部この側です。実際、さっきの18回のうち半分以上は6〜10手・11〜49分で撃っていて、コンテキストが溢れたから切ったわけじゃないんですよね。 続きをやるなら next 、別のタスクなら new 使っているのは2つです。どっちもセッションは切り替えます。違うのは、持っていくもののほうです。 next (同じ話の続きをやる):ゴール/現在地/再開ポイント/主要ファイル/保留・未確定/コミット状態 new (別のタスクに移る):次のタスク/引き継ぐ情報/前提・スコープ 項目は大体共通ですが、テンプレートは付録に置いておきます。分けているのはシチュエーションのほうです。 next を撃つのは、セッションに要らない話が溜まってきたときです。議論の途中で出た脇道とか、もう決着した話とか、そういうものを抱えたまま進むのがしんどくなってくる。決まったことを一回ドキュメントに起こして、そこから続きをやります。 new は、サブエージェントに投げるまでに少し議論が必要なものですね。元セッションの文脈は少しだけ要る、みたいなやつです。今のセッションの続きではないので、必要なぶんだけ持って出ます。 すり合わせた結果が、引き継ぎドキュメントになります AI に書かせたものを、そのまま渡すわけじゃないんですよね。まず余計なものを落とします。引き継ぎドキュメントを書いてというと、Claudeさんはやったことをすべて書きがちです。放っておくと、発注書じゃなくてセーブポイントを作ってくるんですよね。そこには本筋じゃないけど「これ今やったほうがいいかも」みたいな課題がぽろっと出てくることがあって、あれを混ぜると、次のセッションが何をすればいいか分からなくなります。 だから決めていることは2つあります。ひとつめは基準です。選ぶ軸は「次にやること」であって、今やった成果物じゃありません。やった成果物はコミットしておいてもう決定済みにするのが確実ですね。ふたつめは除外で、余計な内容を混ぜないことです。単純に「引き継ぎ書を書いて」だと、セッションで踏んだ罠やそれをどう回避したのか?など結構いろんな情報を詰め込んできます。そういった教訓はファイルなどに別途切り出して保存するのが良いですね。 そのうえで読み返すと、だいたいこの3つが出てきます。 議論の結論が真逆になっている。長いやり取りの末に決めたことが、引き継ぎでは逆に書かれている 決まったことが未決になっている。「保留・未確定」の欄に、もう決めたはずのものが並んでいる 情報がてんこ盛りで読むのがそもそもつらい・余計な情報が書かれている どれも、読まないと気づかない類のズレです。ドキュメントだから読めます。読むから見つかる。見つけたら、人間が直します。 この落として直す往復を抜けたものが、次のセッションに渡る発注書です。冒頭に書いた「なんか AI の返答が荒れたぞ!」の瞬間に、ちゃんと打てる手ができるということですね。 おまけで1つ。「これもやりたい」って思いついたやつは、 new で持ち出せば別ペインで並行してやれます。並列で頭がパンクしてるときも同じです。あとでやろうとか思いついたタイミングで next を作っておけば、思い出さなくても余裕ができた時に作業として再開することができます。 選ぶのは軽くするためじゃありません。自分が読めるようにするためなんですよね。 付録:そのままコピーして使えるもの 本文では「項目は大体共通」と流しましたが、実物が無いと持ち帰れないと思うので置いておきます。 next と new のそれぞれについて、フォーマットと、毎回同じ形で出すためのカスタムコマンドを並べます。中括弧のところを埋めれば、そのまま次のセッションの先頭に貼れます。 続きをやる next のぶん ## 引継ぎ(継続): {トピック名} ### ゴール {このトピックで達成したいこと 1〜2行} ### 現在地(どこまで / どこで止めた) {中途状態を端的に。議論中なら論点、実装中なら未完部分} ### 再開ポイント(最初にやること) {次セッションで最初に実行する具体アクション} ### 主要ファイル - {フルパス}: {1行説明} ### 保留・未確定 - {保留した判断・選択肢とその理由} ### コミット状態 {git status / git log -3 のサマリ} 僕はこのフォーマットを Claude Code のカスタムコマンドにしていて、 /session-handoff-next と打つと生成してファイルに保存するところまでやってくれます。実物から効いてる部分だけ削り出したのがこれです。 .claude/commands/session-handoff-next.md に置けば動きます。 --- description: 同じトピックの継続用引継ぎプロンプトを生成 argument-hint: [トピック名] allowed-tools: Bash, Read, Write, Glob, Grep --- # Handoff: 継続タスク用 同じトピックを次セッションで継続再開するための引継ぎを生成する。 ## Step 1: 継続に必要な分だけ集める 網羅ではなく「復元最小セット」。ゴール/現在地/再開ポイント/主要ファイル/保留・未確定/ コミット状態(`git status` と `git log -3`。未コミットがある時のみ)。 ⚠ 教訓はここに書かない。普遍的な学び(手法・注意点・再利用できるパターン)は別の置き場へ送る。 ここに残すのは、このトピック固有の復元コンテキストだけ。 ## Step 2: 生成する 上の `next` のフォーマットで書く(6セクション固定。これ以上増やさない)。 内容が薄いセクションは見出しごと省く。 ## Step 3: ファイルに保存して開く `docs/tmp/handoff-next-{YYYYMMDD-HHMMSS}.md` に保存し、エディタで開く。 応答本文には保存先のパスだけを出す(全文を再掲しない)。 別のタスクに移る new のぶん 先頭が「次のタスク」なのがポイントで、ここが決まっていないと下の2つを選べません。 ## 引継ぎ(新タスク開始): {次のタスク名} ### 次のタスク {次セッションで取り組む内容を 1〜3行で} ### 引き継ぐ情報(このタスクに関係する分だけ) - {次タスクに効くファイル / 判断 / 制約だけ。無ければ「引き継ぐ成果物なし」} ### 前提・スコープ - {必要なファイルパス・参照ドキュメント} - {既知の制約・やらないこと} コマンドのほうも同じで、Step 1 が本体です。ここが決まらないと選別が始まりません。 --- description: 別タスクへの切替え用引継ぎプロンプトを生成 argument-hint: [次のタスク名] allowed-tools: Bash, Read, Write, Glob, Grep --- # Handoff: 別タスク切替え用 現セッションから、次のタスクに効く情報だけを選んで渡す。 これは「現セッションの完了報告」ではない。 ## Step 1: 次のタスクを確定する 引継ぎ全体の選別基準になるので、ここを最優先で固める。 ⚠ 曖昧なままファイルを作らない。何をするタスクか/現セッションのどの成果物を引き継ぐ必要があるか/ やらないことが不足していたら、必ずユーザーに確認する(推測で埋めない)。 ## Step 2: 次のタスクを基準に選別する 共有するファイル・次タスクに影響する設計判断や制約・引き継ぐべき落とし穴だけを抜く。 無関係な進捗や議論ログは捨てる。引き継ぐものが無ければ「引き継ぐ成果物なし」でよい。 ## Step 3: 生成する 上の `new` のフォーマットで書く。前提情報は最小限にして、AI が自力で見つけられるものは省く。 ## Step 4: ファイルに保存して開く `docs/tmp/handoff-new-{YYYYMMDD-HHMMSS}.md` に保存し、エディタで開く。 フォーマット自体はどうでもよくて、効いてるのは2つの決めごとのほうです。基準は「次にやること」、除外は余計な内容(教訓とか)を混ぜないこと。ここだけ持って帰ってもらえれば、中身は自分の書きやすい形でいいと思います。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Codeとの解釈の違いは、引き継ぎを書かせると露呈する first appeared on SIOS Tech Lab .
はじめに こんにちは!サイオステクノロジーのなーがです。前回、 Claude Code のテストゲートが編集ゼロで12分!?Stop hook が「黙って効かなくなる」まで について書きましたが、あれから個人開発の Python プロジェクトでマルチエージェント運用を続けています。委譲そのものは安定してきました。ただ、そのあいだずっと手を出せずにいた問題が残っていました。 レートリミットです。 サブエージェントを何本も並列で走らせていると、当然トークンの消費は速くなります。そして usage limit に当たった瞬間、走っていたサブエージェントは道半ばで殺されます。ここまでは仕方ない。問題はその後で、 制限が明けて作業を再開したとき、「どこまで終わっていたのか」がどこにも残っていない のです。 結果どうなるかというと、さっき 数千トークン使って終わりかけていた調査を、まったく同じ内容で新しいサブエージェントに投げ直すことになります。1 回の中断で同じ作業のトークンを 2 回払う。これが地味に効きます。 レートリミット自体は昔からあるものですし、これに悩まされていたのも今に始まった話ではありません。ずっと手を出せずにいたのは、 中断そのものを検知する手立てが無い と思い込んでいたからです。ターンが API エラーで打ち切られると、hooks から見れば何も起きないまま会話が終わる。記録を残そうにも、記録するきっかけが無い。そう決めつけて、そのままにしていました。 しかし、ハーネスを自作することで解決できるのではないかと思い自作しました。下記はそのサンプルリポジトリになります。 .claude/ 一式と hook のテストに加えて、本物のレート制限を待たずに中断から再開までを再現する examples/simulate-interruption.sh が入っています。 サンプルリポジトリ : Shotaro-Yoshinaga-sti/claude-code-turn-recovery ところが改めて調べてみると、 StopFailure という hook イベントが とっくに追加されていました 。API エラーでターンが終わったときに発火するものです。追加されたのは Claude Code 2.1.78。手元は 2.1.260 だったので、180 バージョン以上も前の話でした。完全に見落としていたわけです。 しかも、これを実際に組み立てるための部品は StopFailure だけではありません。改めて CHANGELOG を追ってみると、必要なものが 知らないあいだにひととおり揃っていました 。 バージョン 追加されたもの このハーネスでの役割 1.0.41 Stop から SubagentStop を分離 サブエージェントの完了だけを単独で捉えられる 2.0.42 SubagentStop に agent_id / agent_transcript_path 完了記録を agent_id で紐付けられる 2.1.47 Stop / SubagentStop に last_assistant_message 完了時の最終報告をトランスクリプトを解析せずに残せる 2.1.77 Agent の resume を廃止し SendMessage({to: agentId}) に一本化 中断したサブエージェントを agent_id で再開できる 2.1.78 StopFailure 中断の瞬間そのものを検知できる 2.1.98 失敗したバックグラウンドサブエージェントが部分的な進捗を親に報告 長い作業をバックグラウンドに置く判断が成り立つ 2.1.199 API エラーで打ち切られたサブエージェントが部分成果を親に報告 中断時に拾えるものが「無」ではなくなった 面白いのは 2.1.77 と 2.1.78 です。 再開の口( SendMessage )と、中断の検知( StopFailure )が隣り合ったバージョンで揃っていました。 片方だけでは足りません。中断を検知できても再開する手段が無ければ記録は活かせませんし、逆に再開できても何を再開すべきか分からなければ意味がない。両方あって初めて成立します。 つまり「hooks では手が出ない」という私の前提のほうが、とっくに古くなっていました。せっかく揃っているので、 中断を記録して次のセッションで再開するハーネスを自作することにしました。 さきほどのサンプルリポジトリが、その成果物です。 ……そして、組み終えてから公式ドキュメントを読み直して、 もっと大きな前提のほうが崩れました 。このハーネス、ほとんど要りませんでした。 順番が前後しますが、先にその話から書きます。実装だけ見たい方は ハーネスの全体像 まで読み飛ばしてください。 参考: Hooks リファレンス(公式ドキュメント) 先に結論: 公式だけでどこまで戻れるのか 私がハーネスを作った動機は「レートリミットで打ち切られたサブエージェントの成果は丸ごと消える」という思い込みでした。 これが間違いでした。 サブエージェント内の API エラー に、実行場所ごとの挙動が明記されています(v2.1.199 以降)。 バックグラウンド : 失敗とマークされ、親が受け取るメッセージには API エラー名と そのサブエージェントの最後の出力 が含まれる。ドキュメントの言葉を借りれば「部分的な作業は失われません」 フォアグラウンド : すでに何かを出力していれば、その部分出力が「遮断されタスクを完了しなかった」という注記付きで返る。v2.1.200 以降、何も出力していない/ツール呼び出ししかしていないサブエージェントだけが Agent terminated early due to an API error で失敗する そして決定的なのがこちらです。 セッション永続性 :サブエージェントトランスクリプトはセッション内で永続化されます。Claude Code を再起動した後、同じセッションを再開することでサブエージェントを再開できます。 再開されたサブエージェントは、すべての前のツール呼び出し、結果、および推論を含む、完全な会話履歴を保持します。サブエージェントは、新規に開始するのではなく、停止した場所から正確に再開します。 つまり、制限が明けたあとにやることはこれだけです。 claude --continue あとは「さっきの調査を続けて」と頼むだけ。Claude は SendMessage で該当のサブエージェントを起こし、そのサブエージェントは止まった場所から再開します。トランスクリプトはメイン会話とは別ファイルに保存されるので、 メイン会話が圧縮されても影響を受けません 。 自作ハーネスと並べるとこうなります。 自作のほうが明確に劣っています。 ハーネスが次のセッションに渡せるのは JSON の記録とノートの要約ですが、公式はサブエージェントのトランスクリプトそのものを復元します。情報量で勝ち目がありません。 しかも時系列がよくない。部分成果を親に返す修正(2.1.199)が公開されたのは 2026 年 7 月 2 日、私が StopFailure hook を書き始めたのは 8 月 26 日でした。 公式が手当てしてから 2 か月近く経ったあとに、同じ問題を自力で解こうとしていた ことになります。CHANGELOG で StopFailure を見つけたところで満足してしまい、サブエージェント側のドキュメントを読み直さなかったのが敗因です。 それでもハーネスに残る用途 では完全に無駄だったかというと、狭いながら残る場面はあります。 同じセッションを再開しない場合 です。 コンテキストが膨らみすぎて、新しいセッションで仕切り直したいとき /clear した後 cleanupPeriodDays (既定 30 日)を過ぎて、トランスクリプトが掃除された後 そもそも「どのセッションだったか」を人間が思い出せないとき このときに手がかりになるのは、ディスクに残る .claude/recovery/ の中断記録と .claude/agent-notes/ のノートだけです。 SessionStart hook は どのセッションで開いても 発火するので、まっさらな新しいセッションにも前回の中断内容を持ち込めます。ノートのほうも、サブエージェントの最後の 1 発言ではなく「途中で確定した所見」が時系列で残るという違いはあります。 とはいえ、 claude --continue で済む場面のほうが圧倒的に多いです。 まずこれを試して、足りないと感じたときだけ hooks を検討する 、という順序が正解でした。 以降は、その「作ってしまったハーネス」の実装記録です。 StopFailure の癖や、hook で状態を持つときに踏んだ落とし穴自体は別のものを作るときにも効くはずなので、そのまま残しておきます。 中断を捕まえられるイベント: StopFailure 見落としていた StopFailure を、もう少し詳しく見ておきます。 公式ドキュメントによると、入力ペイロードは共通フィールドに加えて次の 3 つを持ちます。 フィールド 型 内容 error string ターンを終わらせたエラー種別 error_details string 追加の詳細(optional) last_assistant_message string 会話に表示されたエラー文言(optional) error に入りうる値も列挙されています。 rate_limit / overloaded / authentication_failed / oauth_org_not_allowed / account_on_hold / billing_error / invalid_request / model_not_found / server_error / max_output_tokens / unknown の 11 種類です。 last_assistant_message には注意が要ります。 Stop や SubagentStop では Claude の発言が入るフィールドですが、 StopFailure では "API Error: Rate limit reached" のような API エラー文字列そのもの が入ります。同じ名前でも中身の意味が違うので、共通の処理でまとめて扱うと取り違えます。 そして重要な制約が 2 つあります。 終了コードは無視される 。エラーはもう起きているので、hook が中断をブロックしたり結果を変えたりはできません。 stdout と JSON 出力も無視される 。decision control は使えず、出力はデバッグログにしか出ません。 つまり StopFailure hook にできるのは「 ディスクに書くこと 」だけです。ここが設計の出発点になります。書いたものを読んで人間(と Claude)に見せる役目は、別のイベントに持たせる必要があります。 その相方が SessionStart です。こちらは逆に、 stdout がそのまま Claude のコンテキストに追加される 数少ないイベントのひとつです(他は UserPromptSubmit と UserPromptExpansion )。 stdout is added to Claude’s context as the first input before the initial prompt. 書く側が StopFailure 、読ませる側が SessionStart 。この 2 つをディスク上の JSON で繋ぐ、というのが今回のハーネスの骨格です。 参考: Hooks リファレンス – StopFailure(公式ドキュメント) 自作したハーネスの全体像 不要になってしまいましたが、自作したハーネスの全体像です。関係するファイルはこれだけです。 .claude/ ├── settings.json # hook の登録と env ├── hooks/ │ ├── agent-call-record.sh # PostToolUse(Agent): 起動を .call.json に記録 │ ├── agent-call-complete.sh # SubagentStop: 完了を .done.json に記録 │ ├── pending_agents_shared.py # 「未完了」判定の2パス走査(共有モジュール) │ ├── turn-failure-record.sh # StopFailure: 中断を recovery/ に記録 │ ├── session-start.sh # SessionStart: 再開ブリーフィングを注入 │ └── agent-calls-gc.sh # UserPromptSubmit: 古い状態を掃除 ├── agent-calls/ # <session_id>/<prompt_id>/*.call.json / *.done.json ├── recovery/ # <session_id>.json(提示後は .json.consumed) └── agent-notes/ # サブエージェントが残す途中成果 時系列で並べるとこうなります。 ポイントは、 「未完了だったエージェント」を判定するための材料が既にあった ことです。前回の記事で作ったリレーガードが、Agent 呼び出しの内容を台帳に記録していました。これを流用します。 ハーネスを実装する 1. Agent 呼び出しの台帳を作る(.call.json / .done.json) まず土台になる台帳です。「起動」と「完了」を別々のイベントで記録します。 起動側は PostToolUse (matcher は Agent )です。ここで注意したいのは、 このイベントは完了ではない という点です。サブエージェントは既定でバックグラウンド実行されるため、 PostToolUse は起動が返った時点( tool_response.status == "async_launched" )で発火します。 それでもここで記録する理由は、 PostToolUse が tool_input.prompt と tool_response.agentId の 両方を持つ唯一のイベント だからです。「どの agent_id がどんな依頼で起動されたか」はここでしか確定できません。 # agent-call-record.sh(Python 部分の抜粋) session_id = as_str(data.get("session_id")) prompt_id = as_str(data.get("prompt_id")) subagent_type = as_str(tool_input.get("subagent_type")) prompt = as_str(tool_input.get("prompt")) agent_id = as_str(tool_response.get("agentId") or data.get("agent_id")) state_dir = os.path.join( state_root, safe_name(session_id, "unknown-session"), safe_name(prompt_id, "no-prompt-id"), ) record = { "agent_id": agent_id, "subagent_type": subagent_type, "norm_lines": normalize_lines(prompt)[:MAX_PROMPT_LINES], "time": time.time(), } # → <session_id>/<prompt_id>/<agent_id>.call.json 完了側は SubagentStop です。 agent_id と最終報告テキストを .done.json に書きます。 # agent-call-complete.sh(抜粋) record = { "agent_id": agent_id, "agent_type": agent_type, "output_lines": normalize_lines(output[:MAX_OUTPUT_CHARS])[:MAX_OUTPUT_LINES], "time": time.time(), } # → <session_id>/<prompt_id>/<agent_id>.done.json これで判定式が立ちます。 .call.json はあるが、対応する .done.json が無い = 中断された瞬間に走っていたエージェント この 2 パス走査( .done.json のキーを集める → .call.json の未完了を抽出する)は、後述するとおり StopFailure 側と SessionStart 側の両方が必要とするので、共有モジュール pending_agents_shared.py に切り出しています。 def collect_done_keys(session_dir: str, prompt_dir_names: list[str]) -> set[str]: """パス1: セッション全体の .done.json キーを1つの集合へ集約する。""" done_keys: set[str] = set() for prompt_dir_name in prompt_dir_names: prompt_dir = os.path.join(session_dir, prompt_dir_name) try: names = os.listdir(prompt_dir) except Exception: continue done_keys.update(n[: -len(".done.json")] for n in names if n.endswith(".done.json")) return done_keys 2. StopFailure で中断を記録する 台帳が揃ったので、中断の瞬間に「何が失われたか」をスナップショットします。 { "hooks": { "StopFailure": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/turn-failure-record.sh\"", "timeout": 10 } ] } ] } } matcher は あえて付けていません 。 rate_limit に絞りたくなるところですが、 overloaded でも server_error でも「作業が飛ぶ」という事情は同じです。全部記録して、区別は読む側( session-start.sh )に任せます。 記録する中身はこんな形です。 record = { "session_id": session_id, "prompt_id": prompt_id, "error_type": error_type, "error_message": error_message, "time": record_time, "branch": git_output(["branch", "--show-current"]).strip(), "git_status_lines": git_output(["status", "--porcelain"]).splitlines()[:MAX_STATUS_LINES], "pending_agents": pending_agents, # 未完了だったエージェント "pending_total": pending_total, # 上限で切り捨てる前の総数 "finished_agents": finished_agents, # このターンで完了済み(再実行させないため) "payload_keys": payload_keys, # 診断用 "payload_preview": payload_preview, # 診断用 } finished_agents を持っているのがポイントです。再開時に「未完了はこれ」だけを伝えると、 既に終わっているエージェントまで念のため走らせ直す という反応が起きがちです。「これは終わっているので再実行しない」を明示的に渡します。 書き込みは tempfile.mkstemp() + os.replace() の原子的置換です。中断はターンの途中で起きるので、読む側が壊れた JSON を掴まないよう「完全な内容が見えるか、古い内容が見えるか」の二択にしています。 fd, tmp_path = tempfile.mkstemp(dir=state_root, prefix=".tmp-", suffix=".json") with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(record, f, ensure_ascii=False) os.replace(tmp_path, path) そして全体が try/except で囲まれ、 何が起きても exit 0 です。 StopFailure は終了コードも stdout も無視されるイベントなので、失敗を通知する手段がありません。静かに諦めるしかないので、fail-open に振り切ります。 3. SessionStart で再開ブリーフィングを注入する 書いたものを読ませる側です。 SessionStart hook の stdout はそのままコンテキストに入るので、Markdown で人が読める形に整形して出します。 記録の引き当ては 2 段構えです。 def pick_record(state_root, session_id): """(パス, 記録, 別セッション由来か) を返す。""" cutoff = time.time() - ttl_seconds() # 既定 24 時間 if session_id: exact = os.path.join(state_root, f"{safe_name(session_id, ...)}.json") record = load(exact) if record is not None and float_or_zero(record.get("time")) >= cutoff: return exact, record, False # 一致しなければ TTL 内で最新の記録を「別セッションの記録」として拾う --continue / --resume は同じ session_id でセッションを開き直すので完全一致が直撃します。一方で、制限に当たった後は新しいセッションを立て直す運用も多いため、一致しなければ TTL(既定 24 時間)内の最新記録をフォールバックで拾い、「別セッションの記録です」と断り書きを添えて出します。 出力されるブリーフィングはこんな感じです。 ## 前回ターンの中断記録 (rate_limit) - 中断の原因になったAPIエラーがまだ続いているかどうかは、この記録からは分からない。 ただし「まだ制限中かもしれない」はサブエージェントへの委譲を控える理由にならない。 制限が明けていれば委譲は普通に動くし、明けていなければメインセッションが自分で 実装しても同じように失敗する - 中断: 2026-09-02 20:41 / You've hit your session limit · resets 8:40pm (Asia/Tokyo) - 未完了だったサブエージェント (2件): - investigator [agent_01AbC...] 認証まわりのエラー処理を洗い出す / … - implementer [agent_01XyZ...] サムネイル生成のキャッシュを追加する / … (古い) - このターンで完了済み (1件): reviewer [agent_01Def...] — 再実行しない - 再開の手順 (上から順に試し、成功した時点で次には進まない): 1. `.claude/agent-notes/` に途中成果が残っていないか先に確認する。 残っていれば、次の指示はそこからの差分だけでよい 2. 未完了エージェントに SendMessage(to: "<agent_id>") を送って再開する。 同じ依頼を新規 Agent で作り直さない 3. SendMessage が届かない (エージェントが既に消えている) 場合は、 残りの作業を新しいサブエージェントに委譲する 4. メインセッションが自分で実装するのは最後の手段 - 組み込みの Explore / Plan は再開できない。 再開できる investigator / implementer に置き換えて起動する 工夫している点をいくつか挙げます。 手順を 1〜4 の順序付きで書く。 「再開してください」だけだと、 SendMessage が届かなかった時点で消去法的に「じゃあメインが自分で実装するか」に落ちます。不達時の出口(手順 3)を明記して、委譲ルールの中で完結できるようにしています。 鮮度に注意を添える。 呼び出しから 1 時間( STALE_AGENT_AGE_SEC )を超えたエージェントには (古い) を付けます。数時間後の再開では、もうプロセスが消えている可能性が高いためです。 切り捨てが起きたことを見せる。 記録側は 12 件、表示側は 8 件で切りますが、切る前の総数( pending_total )も記録しておき、 (他 N 件、上限により省略) と出します。上限が黙って情報を落とすのは避けたい。 一度出したら二度出さない。 提示後は os.replace(path, f"{path}.consumed") でリネームします。削除ではなくリネームなのは、後述の GC が同じ基準で拾えるようにするためです。 なお、 error は unknown を正規の値として取りうるので、空文字と同じく「原因不明」に寄せています。ここを素通しにすると、ドキュメント記載の正規値そのものを経路として、日本語のブリーフィングに英語の (unknown) が混ざります。 4. サブエージェント側に途中成果を書かせる hooks だけでは埋まらない穴があります。 未完了のエージェントを再開できても、そのエージェントが何を掴んでいたかは本人しか知らない という点です。親に返るのは最後の 1 発言だけで、その全履歴は同じセッションを再開しないと辿れません。新しいセッションで仕切り直したときに SendMessage が届かなければ、そこで打ち切りです。 そこで、エージェント定義そのものに「途中成果の保全」を書いています( .claude/agents/investigator.md )。 ## 途中成果の保全 調査が数分を超えそうなら、確定した所見をその都度 `.claude/agent-notes/<YYYYMMDD>-investigator-<短いスラッグ>.md` に追記する。 - なぜ: レート制限などのAPIエラーで打ち切られると、親に渡るのは最後の1発言だけで、 それも親のターンごと落ちれば次のセッションには残らない。ノートが無ければ 再開時に一から流し直すことになる。 - ノートの1行目に依頼の1行要約を書く。中断記録と突き合わせるための目印になる。 - 最終報告はノートへの参照と要約でよい。同じ内容を二重に書かない。 - 追記は `cat >> <パス> <<'EOF'` で行う。**書いてよいのは自分のノートだけ**で、 ソースツリーは読み取り専用のまま。 読み取り専用エージェントに書き込みを許すのは一見矛盾していますが、 書いてよい場所を自分のノートだけに限定 することで、ソースツリーの読み取り専用性は保っています。 再開ブリーフィングの手順 1 が「まずノートを見る」になっているのはこのためです。ノートが残っていれば、 SendMessage が届かなくても「どこまで終わっていて、次に何をするか」を書いた 新しい指示 を作れます。前回の依頼文を丸写しするのではなく、差分だけを渡す。ここは前回の記事で作ったリレーガードの思想とも一致します。 5. 設定側で被害そのものを減らす 記録と再開の仕組みとは別に、 settings.json の env で被害の総量そのものを削っています。 { "env": { "CLAUDE_AUTO_BACKGROUND_TASKS": "1", "CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY": "6" } } CLAUDE_AUTO_BACKGROUND_TASKS=1 は、約 2 分を超えたサブエージェントを自動でバックグラウンドへ移す設定です。前節のとおり、まだ何も出力していないフォアグラウンドのサブエージェントだけは中身ごと失われるので、長い作業をバックグラウンドへ逃がすのはそのまま保険になります。 なお v2.1.198 以降、サブエージェントは既定でバックグラウンド実行になりました。Claude が「結果を待ってから続けたい」と判断した場合はフォアグラウンドを選ぶので、この設定はそこに掛ける保険という位置づけです。 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY=6 は並列実行の上限を下げるものです。1 回の中断で巻き添えになる作業を減らす狙いですが、下げすぎると通常の調査が目に見えて遅くなります。6 はそのトレードオフの落としどころでした。ただしこの変数は 公式ドキュメントに記載がありません 。挙動が変わっても不思議はないので、無くても困らない設定として扱っています。 6. 状態ファイルを GC する 放っておくと .claude/agent-calls/ も .claude/recovery/ も溜まる一方なので、 UserPromptSubmit で掃除します。 GC_DAYS="${HOOK_STATE_GC_DAYS:-7}" find "$CALLS_DIR" -mindepth 1 -maxdepth 1 -type d -mtime "+$GC_DAYS" -exec rm -rf {} + 2>/dev/null || true find "$RECOVERY_DIR" -mindepth 1 -maxdepth 1 -type f \ \( -name '*.json' -o -name '*.json.consumed' \) -mtime "+$GC_DAYS" -delete 2>/dev/null || true find "$NOTES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' -mtime "+$GC_DAYS" -delete 2>/dev/null || true 保持は 7 日。中断記録もノートも、7 日経てば再開の役には立ちません。 -name で対象を絞っているのは、同じ場所に置いた無関係なファイルを巻き込まないためです。 なお、ブリーフィングとして提示する条件(24 時間)と GC の保持期間(7 日)は別物です。24 時間を過ぎた中断は「別の作業の話」として提示しませんが、ファイルは調査用に 7 日残ります。 運用して分かった落とし穴 ここからが本題かもしれません。素直に作ると壊れるところが、思ったより多くありました。 完了判定を prompt_id 単位でやると完了済みを未完了と誤認する 最初の実装では、 .call.json と .done.json の突き合わせを 同じ prompt_id ディレクトリの中だけ でやっていました。素直な設計に見えます。 これが盛大に外れました。バックグラウンドのサブエージェントは ユーザーの発言をまたいで生き続ける ので、起動時の prompt_id と完了時の prompt_id は日常的に別物になります。同じ prompt_id の中だけを見ると、既に完了しているエージェントが軒並み「未完了」に化けます。 修正は、 .done.json の集約範囲を セッション全体 に広げることでした。これが collect_done_keys() がセッション配下の全 prompt_dir_names を舐めている理由です。 UUID の辞書順で切り捨てると本命が枠から漏れる 記録する未完了エージェントは 12 件を上限にしています。当初はこれを os.listdir() のソート順、つまり prompt_id の辞書順 のまま切っていました。 prompt_id は UUID です。 発行順とはまったく無関係 なので、辞書順で後ろに来た prompt_id に「いま本当に走っているエージェント」がいると、上限に押し出されて落ちます。実際、古い prompt_id に未完了の .call.json が 15 件、現在の prompt_id に本命が 1 件、という配置を作ったところ、本命が pending から完全に脱落しました。 いまは 呼び出し時刻の降順 に並べてから切っています。 time が無い/不正なレコードは最も古い扱いです。 pending_with_time.sort(key=lambda pair: pair[0], reverse=True) pending_total = len(pending_with_time) pending = [entry for _, entry in pending_with_time[:MAX_AGENTS]] 「上限で切る」実装を書くときは、 何の順で切っているか を必ず意識する、という教訓でした。 サブエージェントだけが殺されると StopFailure が発火しない これが一番気づきにくかったケースです。 レートリミットは、 サブエージェントだけを殺してメインのターンを生かす ことがあります。メインが生きているのでターンは正常終了扱いになり、 StopFailure は発火しません。つまり .claude/recovery/ に記録が作られない。 その場でメインが気づけば SendMessage で再開できますが、気づかないままセッションが終わると、走りかけのサブエージェントの存在は次のセッションに一切伝わりません。ハーネスの穴です。 塞ぎ方は、 session-start.sh に フォールバック走査 を足すことでした。中断記録が「無い」ときに限り、 .claude/agent-calls/ の台帳を セッションをまたいで 直接走査します。 def find_orphan_agents(calls_root, current_session_id, now): """中断記録が無いとき、他セッションに走ったまま終わったサブエージェントを探す。""" for session_dir_name in session_dirs: if session_dir_name == safe_name(current_session_id, "unknown-session"): continue # 現在のセッションはまだ生きている可能性があるので除外 done_keys = collect_done_keys(session_dir, prompt_dirs) pending_calls = collect_pending_calls(session_dir, prompt_dirs, done_keys, now, ttl_sec) ... 面白いのは、 turn-failure-record.sh は現在のセッションだけを見て、 session-start.sh は現在のセッションを除く全セッションを見る という、ちょうど裏返しの関係になっていることです。どちらも同じ 2 パス走査を使いますが、対象セッションの選び方だけが違う。だから pending_agents_shared.py は 2 パス判定そのものだけを持ち、 対象セッションの選び方・並べ替え・件数上限は呼び出し側の関心事 として意図的に外に置いています。 なぜモジュールに切り出したかというと、同じロジックが 2 箇所にあった時期に実際に事故ったからです。 .done.json の集約範囲をセッション全体に広げる修正が session-start.sh 側にしか入らず、 turn-failure-record.sh は prompt_id スコープのまま取り残されて、いま走っているエージェントが pending から脱落する退行が生まれました。 ブリーフィングに検証できない断定を書いていた これは実装バグではなく、 文章のバグ です。 初期のブリーフィングには、こう書いていました。 この記録が出ている = 新しいセッションが開始できている = 中断の原因になった API エラーは既に解消している 一見もっともらしいのですが、成立しません。 SessionStart hook は ローカルで走り 、モデルへの API リクエストが成功したことを前提にしません。制限中でも CLI は起動できて、その時点でブリーフィングは注入されます。 しかも厄介なことに、これはハーネスが毎セッション自動で注入する文言です。**モデルにはそれを疑う手段がありません。**検証不能な事実主張を、権威ある前提として毎回渡していたことになります。 いまは事実主張だけを削って、行動指示は残しています。 中断の原因になった API エラーがまだ続いているかどうかは、この記録からは分からない。ただし「まだ制限中かもしれない」はサブエージェントへの委譲を控える理由にならない。制限が明けていれば委譲は普通に動くし、明けていなければメインセッションが自分で実装しても同じように失敗する 行動指示は事実主張に依存せずに書ける、というのが学びでした。「制限は明けている から 委譲してよい」ではなく、「明けていてもいなくても自力実装に利点はない から 委譲する」と書けば、前提が崩れても指示は生き残ります。 「提示済み」の印で台帳の主データをリネームしていた 最後は設計の筋の悪さの話です。 孤児エージェントの走査には「一度提示したら二度出さない」印が要ります。中断記録のほうは .json → .json.consumed へのリネームでやっていたので、同じ考え方で .call.json → .call.json.presented とリネームしていました。 これが良くなかった。 .call.json は リレーガード・編集ガード・中断記録の 3 つの hook が共有で読む台帳の主データ です。中断記録の .json が「消費したら役目が終わる通知」なのとは性質が違います。 当時実害が出ていなかったのは、孤児スキャンが現在のセッションを除外していて、他の 3 つの hook は現在のセッションしか見ていなかったからです。偶然です。将来どれかの hook がセッションをまたいで .call.json を読むようにした瞬間、提示済みのものだけが黙って見えなくなります。 --resume で古いセッションを開き直した場合も同じ形で顕在化します。 いまは主データを変更せず、サイドカーの .presented.json を別に置く方式です。 def presented_marker_path(call_path: str) -> str: if call_path.endswith(".call.json"): return call_path[: -len(".call.json")] + ".presented.json" return call_path + ".presented.json" GC はセッションディレクトリごと rm -rf するので、入れ子のサイドカーも一緒に回収されます。 共有される主データに、片方の読み手の都合の状態を混ぜない。 当たり前のようでいて、既存のリネーム方式を横展開した結果うっかりやってしまいました。 実際に動かしてみる このハーネスは意図的にテストで固めています。hook は普段は黙って動くものなので、壊れても気づけないからです。 uv run python -m unittest discover -s .claude/tests -t .claude/tests -v 中断記録まわりのテストだけで、 turn-failure-record に 31 本、 session-start に 27 本、共有モジュールに 13 本、GC に 6 本あります。テストが守っている性質は、テストファイルの docstring に書いています。 """turn-failure-record.sh (StopFailure hook) のテスト。 このhookが守っている性質: 1. 「.call.json はあるが .done.json がない」= 中断時に走っていたエージェント。 この判定を誤ると、再開時に完了済みの調査まで流し直してトークンを二重に捨てる。 2. 未完了の判定はセッション配下の *全* prompt_id を見る。バックグラウンドの サブエージェントは発言をまたいで生き続けるため、直近の prompt_id だけを 見ると取りこぼす。 3. 何が起きても exit 0 で、書けないときは何も残さない(fail-open)。 """ 実際に記録された JSON も見てみましょう。これは執筆中にログインが切れて中断したときのものです( rate_limit ではありませんが、記録の形は同じです)。 session_id と prompt_id は実際の値をマスクしています。 cat .claude/recovery/*.json | python3 -m json.tool { "session_id": "00000000-0000-0000-0000-000000000000", "prompt_id": "11111111-1111-1111-1111-111111111111", "error_type": "authentication_failed", "error_message": "Login expired · Please run /login", "time": 1788485303.017802, "branch": "main", "git_status_lines": [], "pending_agents": [], "pending_total": 0, "finished_agents": [], "payload_keys": [ "cwd", "effort", "error", "hook_event_name", "last_assistant_message", "prompt_id", "session_id", "transcript_path" ], "payload_preview": { "effort": "{\"level\": \"high\"}", "hook_event_name": "StopFailure" } } payload_keys と payload_preview を診断用に残しているのが効きます。実際、 error / error_details / last_assistant_message というフィールド名はドキュメントどおりでしたが、 将来ペイロードの形が変わったときに同じやり方で気づける よう、生のキー一覧の記録は続けています。実測が安定した後も消していません。 そして、 error_type にも error_message にも候補キーが埋まらなかった場合の保険として、メッセージ本文からの推定も入れてあります。 RATE_LIMIT_HINT = re.compile(r"rate limit|usage limit|429|529|overloaded", re.IGNORECASE) if not error_type and RATE_LIMIT_HINT.search(error_message): error_type = "rate_limit" rate_limit / usage limit / 429 / 529 / overloaded は、いずれも「作業が飛ぶ」という点で扱いが同じです。大まかな推定で十分、という割り切りです。 さいごに 今回は、レートリミットでの中断を StopFailure で記録して SessionStart で再開ブリーフィングとして注入するハーネスを紹介しました。そして、 それが公式機能でほぼ代替できることに、作り終えてから気づいた 話でもあります。 要点をまとめます。 まず claude --continue を試す。 サブエージェントのトランスクリプトはセッション内で永続化され、再起動後も同じセッションを再開すれば止まった場所から再開できます。今回私が自作したハーネスより多くを復元します。 hooks を書く前に、公式ドキュメントの現在地を確認する。 CHANGELOG で使いたいイベントを見つけただけでは足りませんでした。「そもそもこの問題はまだ残っているのか」を、機能側のドキュメントで確かめるべきでした。 StopFailure はディスクに書くことしかできない (stdout も終了コードも無視される)。読ませる役目は SessionStart に持たせます。 未完了の判定は .call.json と .done.json の突き合わせ 。ただしバックグラウンドのサブエージェントは発言をまたぐので、 .done.json はセッション全体で集約します。 ハーネスが毎回注入する文言に、検証不能な断定を混ぜない。 モデルには疑う手段がありません。今回の私がまさに、検証していない断定を前提に半月ぶんの実装を積み上げていました。 hook で組む「ハーネス」は、Claude 本人の判断力に頼らずに運用ルールを機械的に効かせられるのが強みです。一方で今回のように、 ハーネス自身のバグは静かに効いて、気づいたときには何セッションも損をしている という性質もあります。テストを厚めに書いておくのは、その保険としてかなり効きました。 ただ今回いちばん効いたのは、テストではなくドキュメントの読み直しでした。Claude Code は更新が速いので、半年前の「できない」が今日も「できない」とは限りません。 とはいえ、まるまる無駄だったとも思っていません。 StopFailure が stdout も終了コードも捨てること、バックグラウンドのサブエージェントがユーザーの発言をまたいで生きること、 prompt_id が中断をまたぐと食い違うこと。 このあたりは自分で組んでみるまで知らなかった ことばかりで、hook でセッションをまたぐ状態を持つときの勘どころは、そのまま次に使えます。公式に追い抜かれたのは悔しいですが、Claude Code の内側がどう動いているかを一段深く知れたのは収穫でした。 同じように hooks で何かを塞ごうとしている方は、着手前に公式ドキュメントを一周してみてください。そのうえで、それでも塞ぎたい穴が残っていたら、作ってみる価値はあると思います! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Code のレートリミット中断対策を hooks で自作したら、公式ドキュメントに答えが書いてあった first appeared on SIOS Tech Lab .
もう入っている Chrome を、そのまま触らせる Chrome DevTools MCP は、いま普段使っている Chrome をそのまま使います。自動化用のブラウザを別に落とす手順が要らないので、入れるのはコマンド1本です。動かしてみると立ち上がってくるのは見慣れた Chrome とほぼ同じ画面で、そこの安心感が大きかったですね。 そうなると素朴な問いが1つ残ります。ブラウザも触らせたいけど、ログインが要る画面はどうするのか。先に環境を作って、そこから順に書きます。 先に1つだけ前提があります。 Node.js が入っていること です。この後のコマンドで使う npx は Node.js に同梱されているので、まっさらな Windows では叩けません。確かめたのは v22.15.0 です。 Node.js の入れ方そのものはこの記事では扱いません。普段から開発している人なら手癖で終わる話ですし、そうでない人は Claude に「Windows に Node.js を入れたい」と聞けば、いまの環境に合った手順を出してくれます。ここに書いてもすぐ古くなるところなので、そちらに任せます。 入れ方そのものに新しいところは何もないので、 公式の Get started も並べておきます。Gemini CLI や Codex、JSON で設定を書く形はそちらにまとまっています。Claude Code についてはプラグイン経由の入れ方( /plugin marketplace add ChromeDevTools/chrome-devtools-mcp → /plugin install chrome-devtools-mcp@chrome-devtools-plugins )も案内されていて、そちらは MCP に加えて Skills も一緒に入ります。この記事は素の MCP 登録だけで進めます。 龍ちゃん 特別な事情がない方は!Skillsも一緒に入れておいたほうがいいから公式を見ようね! この前提を踏まえて、入れるのはコマンド1本です。 claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest これだけです。オプションは1つも要りません。打ったディレクトリ限定で登録されるので、どこでも使いたいなら --scope user を足します。 追加したら Claude Code のセッションを一度開き直します。開き直さないとツールが生えません。入っているかはこれで見えます。 $ claude mcp list Checking MCP server health… chrome-devtools: npx -y chrome-devtools-mcp@latest --user-data-dir=C:\Users\<ユーザー名>\.chrome-automation-profile --viewport=1400x900 --screenshot-format=webp --screenshot-quality=70 --screenshot-max-width=1400 - ✔ Connected 僕の環境はオプションを足しているので長いですが、素の登録ならもっと短く出ます。見るのは末尾の ✔ Connected だけです。ただしこれで分かるのはサーバーの疎通までで、実際に動くかは1回 list_pages を呼ばせるまで分かりません(この話は最後にもう一度出てきます)。 今回は Windows で完結させています。WSL2 や devcontainer からホストの Chrome を叩く構成は扱いません。 入ったので、何が取れるのかを先に見ておきます。自分のブログの公開ページを開いて、構造をテキストで取ってみます。 navigate_page(pageId=1, type="url", url="https://<自社ブログ>/") → Successfully navigated to https://<自社ブログ>/. ## Pages 1: SIOS Tech Lab - エンジニアのためになる技術トピックス (https://<自社ブログ>/) [selected] take_snapshot を呼ぶと、ページの構造がテキストで返ってきます。要素に番号(uid)が振られているので、座標を推定しなくても操作できるんですよね。 uid=1_0 RootWebArea "SIOS Tech Lab - エンジニアのためになる技術トピックス" url="https://<自社ブログ>/" uid=1_1 banner uid=1_218 StaticText "© 2026 SIOS Tech Lab All rights reserved." 使い分けは1行で足ります。操作するためならスナップショット、人に見せる・見た目を判断するためならスクリーンショットです。 ブラウザ操作なら、だいたい一通りできる ここまでスナップショットとスクリーンショットしか出していませんが、道具はもっとあります。公式の ツール一覧 を数えると、2026年9月の時点で57個ありました。 ブラウザ操作に関わることなら、だいたい一通りできる と思っていいです。 まずユーザー操作のほう。クリック、テキスト入力、フォームの一括入力、ドラッグ、ファイルのアップロード、キーの送信、ダイアログの応答。タブの開閉と切り替え、画面サイズやデバイスのエミュレーションもあります。人が画面でやっていることは、だいたい指示に置き換わります。 うれしいのはここからです。F12 で開く開発者ツールのタブで見ていたものは、だいたいそのまま取れます。ネットワークのリクエスト一覧とヘッダ(Cookie も入ります)、パフォーマンスのトレースから Core Web Vitals(LCP・INP・CLS)、コンソールのメッセージ。画面の見た目ではなく数字が返るので、そのまま AI に読ませて判断させられるんですよね。 いま議論中の話も1つおまけで書いておきます。ヘッド付きの Chrome を触らせても、OS のマウスカーソルは動きません。入力は CDP のレイヤで注入されるので、隣で見ている人にはエージェントがどこをクリックしたのか分からないんですよね。そこで「ゴーストカーソルを重ねて見せる」 提案 が上がっていて、実装まで済んだ PR も1本ありましたが、ページに DOM を挿し込む形は避けたいというメンテナのコメントが付いて、いまは意見を集めている段階です(2026-09-07 に確認)。この辺はすぐ動くので、気になる人は issue を直接見てください。 動くのは、普段使いとは別のプロファイル オプション無しで動くのは、chrome-devtools-mcp が自前の場所に専用のプロファイルを作ってくれるからです。これは試してみるとわかりますが、Chrome 136 以降、既定のプロファイルのままだとリモートデバッグの指定が無視されるんですよね。理由は 公式のリンク で。 要するに 普段使いの Chrome とは別のプロファイルで動く 、という前提が最初から満たされているわけです。 置き場所を自分で決めたいときだけ、 --user-data-dir で渡します。僕がそうしているのは、後から中を見られるようにしておきたかったからです。ほかにも画面サイズやスクリーンショットの形式を指定するオプションがありますが、最初は1つも要りません。要るようになったら 公式のオプション一覧 で必要なものだけ拾ってください。 専用のプロファイルには、本物のログイン状態がそのまま乗ります。フォルダごと別の PC にコピーしても、そのままでは使えない作りになっています(ここは自分では試していません)。ただこれを「安全」とは読まないでほしくて、守れているのは持ち出しだけで、同じ PC の中からは触れてしまうんですよね。だから普段使いのプロファイルと分かれているのは、動かすための条件であると同時に、被害を閉じ込める線でもあるんだと思っています。 ログイン画面に来たら、エージェントはどうするのか ここからが本題です。僕の前提は1つで、 ID とパスワードはエージェントに渡しません 。認証が要る画面を触らせたいときも、そこだけは人が打つ。その前提で指示を先に書いておきます。 実際に投げたプロンプトの末尾がこれです。 前提: ログインは人がやる — 認証画面が出たら止まって依頼する。 そう書いておくと、エージェントは認証フォームを見つけた時点で入力を試さずに止まります。止まったときの出力がこれです。 ここで止まります。**手動ログインをお願いします。** ブラウザは `<自社ブログ>/wp-login.php` を開いた状態です(`pageId=1`、入力前の1枚は取得済み)。 お願いしたいこと: 1. その Chrome ウィンドウで**ログインしてください**(ID/パスワードは私は一切触りません) 2. ⚠ ログイン後、**管理画面のまま置いておいて構いません** — 私は管理画面をスナップショットも スクショもせず、公開ページへ直接 navigate して戻します 3. 終わったら「**終わった**」と言ってください。それを合図に再開します 止まり方として効いているのは3つあると思っていて。頼みごとが1つに絞られていること(ログインして、だけ。他の判断を人に投げていない)、やらないことを明言していること(ID・パスワードには触らない、管理画面は撮らない)、再開の合図を先に決めていること(人が次に何を言えばいいか迷わない)です。 ただしこれは規約であって、強制ではありません。フォームに入力するツール自体は生きているので、絶対に触らせたくないなら、ツールの許可設定の側で落とすことになります。 画面はこれ1枚だけで、空欄のフォームです。エージェントはここに一度も触っていません。 人が返した合図は「ログインした」でした。依頼したのは「終わった」だったんですけど、今回はこれでも再開できています。 再開の1手目は list_pages にします。これも指示に先に書いておく一行です。ブラウザを開き直すと番号が振り直されることがあって、そのときは MCP 自身が「再起動されたので番号が変わった」と教えてくれます。古い番号のまま呼ぶと失敗するので、1手目は取り直しにしておくと安全なんですよね。 ついでに、採番の確認はそのまま「いま何が開いているか」の確認にもなります。 list_pages() → ## Pages 1: ダッシュボード ‹ SIOS Tech Lab — WordPress (https://<自社ブログ>/wp-admin/) [selected] ログインすると管理画面に入れます。ただ今回は中を見ていなくて、タイトル行がテキストで出ているだけで、そのまま公開ページへ戻しました。 戻した先で撮ったのがこれです。さっきと同じ URL、同じ位置で、上端に管理バーが増えているだけです。 これで受け渡しが成立しています。人が担ったのはログインだけで、その前後はエージェントが動いているんですよね。 ひとつ気をつけどころがあります。認証の先へ入るということは、 そこから先はログインした人の権限で動く ということです。読める範囲も押せるボタンも、認証した自分と同じになります。ID とパスワードを渡していないことと、任せた範囲が狭いことは別なので、管理者のアカウントで入るのか、要る権限だけのアカウントで入るのかは、触らせる前に決めておいたほうがいいです。 なお、ここで書いているのは自分たちのサイトでの話です。他社のサービスを自動で操作させてよいかは規約の話が別に要るので、この記事では扱いません。 毎回ログインし直すことになるの? プロファイルに何が乗るのか、認証を誰が担うのか。ここまでの2つは、最後に1つの問いでつながります。ログインした状態は、どこまで残るのか。ここは層が2つあるので分けて書きます。 1つは、プロファイルに乗る Google アカウントのサインインです。こちらは残っていて、閉じて開き直しても入ったままでした。確かめたのは約8分後、約28時間後、いちばん長いところで約6日2時間後です。 もう1つは、そのサイトが自前で持っているセッションです。今回の自社ブログはこちら側で、ブラウザを閉じたらログインし直しになりました。ウィンドウを普通に閉じて(強制終了はしていません)、約25秒後に開き直しても、管理バーは消えていました。 なぜ残るのか、なぜ残らないのかは追っていません。ログイン状態がどこに保存されるかで変わるところなので、どちらも振る舞いだけ書いています。 残らないなら残るようにしたい、と思うところですが、サイト側の認証情報を自分で取り出して保存する方向へは行っていません。保存した時点でそれが流出の対象になりますし、次からは人が一度も通らずに入れてしまうからです。最後の防壁となる人間の手作業が、丸ごと無くなるんですよね。動かしているあいだはブラウザを見ておいて、挙動に違和感があったり、なんかヤバいなと思ったらパスワードを変える、くらいは構えておくといいと思います。 ただ、Google のサインインだけは自分で保存しなくても残ります。人が毎回通るのはサイト側のログインのほうだけで、そのプロファイルは Google に入れる状態のまま置いてある、と思っておいたほうがいいです。 大事なのは、残らなくても認証の切り分けは成立しているということです。見返りは「次から要らなくなる」ではなく、今この1回、人が触るのはログインだけで済むということなんですよね。 じゃあ次は何を渡せるのか 冒頭の問いを回収すると、ログインが要る画面はどうするのか、答えは人が1回入って、その先を渡す、です。 渡せる仕事はまだあります。 list_network_requests で通信を、 list_console_messages でコンソールを取れるので、「この画面のここが遅い」「エラーが出ていないか」をそのまま調べさせられるんですよね。 ここで使い分けを1つ。開発の現場では、僕は Playwright のほうを使っています。テストを書く場所ですし、同じ手順を何度も回すならコードで持っておくほうが確実なので。入口は Playwright MCP で始めるブラウザ自動化 に書きました。Chrome DevTools MCP が効くのは、日常のちょっとした動作を1回やらせたいときです。わざわざ環境を作るほどではない、でも手でやるのは面倒、くらいの仕事ですね。正直、Windows に Playwright の環境を入れるのが面倒でずっと手が止まっていた、というのも僕の側の事情としてはあります。 反復するならやはり Playwright を書いたほうがいいです。楽なのは着手であって、運用ではないので。 最初の一歩は1つだけです。 claude mcp add を1本打って、「自分のブログのトップを開いて、構造を取ってみて」と頼んでみる。テキストが返ってきたら、そこから先は渡せます。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Chrome DevTools MCP を Windows に入れる。ログインだけ人がやる first appeared on SIOS Tech Lab .
こんにちは、OSSよろず相談室のSKです。 OSS に関するお問い合わせが日々寄せられる中で、今回は Linux における pam_faillock によるアカウント一時ロック に関連して寄せられたお問い合わせをご紹介します。 以下のような問い合わせがありました。 Red Hat Server 環境にて、一時的にログインができなくなる事象が発生しました。 一般ユーザがリモートからログインしたところ、認証エラーとなりログインできませんでした。 管理者(root)ユーザに切り替えて passwd -S コマンドで確認したところ PS (パスワード設定済み・有効)と表示されており、アカウントロック( LK )にはなっていません。 また、特に解除操作を行わなくても一定時間経過すると正常にログインできるようになります。何が原因なのでしょうか? passwd -S コマンドは /etc/shadow ファイルを参照しますが、 pam_faillock による一時ロック情報は /etc/shadow ではなく専用のディレクトリ( /var/run/faillock/ )で管理されているため、 passwd -S では確認できません。 今回は、このやり取りに関連して、「 pam_faillock とは何か」「 passwd -S で確認できない理由」「 faillock コマンドでの確認方法」「Linux(Red Hat系)での動作検証手順」について解説します。 pam_faillock とは? pam_faillock は、Linuxの認証システム(PAM)で提供されているセキュリティモジュールです。 短時間に指定された回数以上の認証失敗(パスワード間違いなど)が発生した際に、ブルートフォース攻撃(総当たり攻撃)などの不正アクセスを防ぐ目的で、該当アカウントを一時的に自動ロックします。 なぜ passwd -S で確認できないのか? passwd -S コマンドは /etc/shadow ファイルを参照 してアカウントのステータス( PS : パスワード有効、 LK : ロック等)を判定します。 passwd -S コマンド出力例 testuser PS 2026-09-01 0 99999 7 -1 (Password set, SHA512 crypt.) 一方、 pam_faillock による一時ロック情報は /etc/shadow を書き換えるのではなく、専用のディレクトリ(デフォルト: /var/run/faillock/ )に失敗履歴として記録・管理 されます。 そのため、 /etc/shadow 上は正常( PS )に見えても、 pam_faillock の失敗カウンタが上限に達していることで認証が拒否される現象が発生します。 システムログと設定パラメータ システムログ(/var/log/secure)の出力例 pam_faillock により一時ロックが発生すると、 /var/log/secure に以下のようなログが出力されます。 Sep 01 13:32:06 server sshd:pam_unix(sshd:auth): authentication failure; ... user=<対象ユーザ名> Sep 01 13:32:40 server sshd:pam_unix(sshd:auth): authentication failure; ... user=<対象ユーザ名> Sep 01 13:36:01 server sshd:pam_unix(sshd:auth): authentication failure; ... user=<対象ユーザ名> Sep 01 13:36:01 server sshd:pam_faillock(sshd:auth): Consecutive login failures for user <対象ユーザ名> account temporarily locked 設定パラメータ例(/etc/security/faillock.conf) /etc/security/faillock.conf の代表的な設定項目は以下の通りです。 deny = 3 fail_interval = 900 unlock_time = 1800 deny = 3 : 3回連続で認証失敗するとロックされます。 fail_interval = 900 : 15分間(900秒)の中で発生した失敗回数をカウントします。 unlock_time = 1800 : ロック発生後、30分間(1800秒)経過すると自動的にロックが解除されます。 注意点 パスワード変更では解除されない :ロック中にパスワード変更を行っても pam_faillock のロック状態は解除されません( passwd と pam_faillock の管理領域が独立しているため)。 失敗試行の繰り返しによる延長 :ロック中にログイン試行を繰り返すと新たな失敗履歴が追加され、ロック期間( unlock_time )がさらに延長されることがあります。 faillock コマンドによるロック状態の確認方法 pam_faillock による認証失敗履歴およびロック状態を確認するには、 faillock コマンド を使用します。 faillock --user <対象ユーザ名> 実行結果の例: When Type Source Valid 2026-09-01 15:25:51 TTY pts/1 V 2026-09-01 15:25:59 TTY pts/1 V 2026-09-01 15:26:06 TTY pts/1 V Valid 列に V が表示されている試行が有効な失敗記録としてカウントされており、これが指定回数( deny の値)に達するとロック状態となります。 Linux での動作検証手順 実際に Linux(今回は Red Hat Enterprise Linux 8)環境で pam_faillock の動作を確認する手順例です。 1. テスト用ユーザの作成 # useradd testuser # passwd testuser 2. SSSDの最小構成設定と起動 # cat << 'EOF' > /etc/sssd/sssd.conf [sssd] config_file_version = 2 services = nss, pam domains = files [domain/files] id_provider = files EOF # chown root:root /etc/sssd/sssd.conf # chmod 600 /etc/sssd/sssd.conf # systemctl enable --now sssd 3. authselect による faillock の有効化 # authselect enable-feature with-faillock # authselect apply-changes # authselect current 4. faillock.conf の設定 # cp -p /etc/security/faillock.conf /etc/security/faillock.conf.org # cat << 'EOF' >> /etc/security/faillock.conf deny = 3 fail_interval = 900 unlock_time = 60 EOF 5. 意図的な認証失敗とロック確認 一般ユーザへ切り替えた上で、わざとパスワードを間違えてログインを試みます。 # su - testuser # パスワードを3回以上間違える その後、root ユーザで失敗履歴を確認します。 # faillock --user testuser まとめ Linux で「パスワード設定は正常( PS )なのにログインできない」場合は、 pam_faillock による一時ロック を疑い、 /var/log/secure や faillock --user <ユーザ名> コマンドで状態を確認することが有効です。 ご覧いただきありがとうございました! 参考ドキュメント  pam_faillock(8) — Linux manual page   https://man7.org/linux/man-pages/man8/pam_faillock.8.html  faillock(8) — Linux manual page   https://man7.org/linux/man-pages/man8/faillock.8.html ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post OSSサポートの現場から!一時的なアカウントロックの原因とは first appeared on SIOS Tech Lab .
ども!最近はペインを何枚も並べてClaude Codeを転がしてる龍ちゃんです。 Claude Codeの返信、長くて読むのがしんどくないですか 聞いたことにはちゃんと答えてくれるんだけど、そこまで詳しく要らなかったな、ってなること。あれ、output styleで変えられます。 公式によると、output styleが変えるのは答え方だけで、知っていることのほうは変わらないそうです。詳しくは 公式のOutput stylesドキュメント へ。 今日書くのは、これを作業ごとに、起動のタイミングでセットする運用です。僕は作業ごとにペインを立てていろんな種類の作業をやってるんですけど、「この作業のときはこう答えて」「こっちのときはこう聞くから」って毎回説明し直してたんですよ。それが起動時の一言で済むようになります。最後に、自分で書いた文言が実際の返答をどれくらい変えたかを測った結果も並べます。どのスタイルが正解かみたいな良し悪しの話はしません。 そもそもoutput styleって、どこに何を書くやつなんでしょうか 変えるのは「答え方」で、「知ってること」じゃないです output styleは、システムプロンプトを書き換える層です。公式にも「Output styles change how Claude responds, not what Claude knows」と書いてあって、日本語にすると「どう答えるかを変えるだけで、何を知っているかは変えない」ということですね。 CLAUDE.mdやskillに書くのは「この作業ではこれを見ろ」「この規約に従え」みたいな答えの中身を作る側の話で、output styleに書くのは出てきた答えをどう見せるかの話です。ここが混ざると「書いたのに効かない」になるので、僕の中ではこの2つで割り切ってます。 組み込みのスタイルは5つ入っています。 Default: 今まで通りの、既定のシステムプロンプトです Concise: 結論を先に言って前置きを削り、短く返します Explanatory: 作業の合間に、学びになる説明を挟んでくれます Learning: 学習モードで、コードの一部を TODO(human) にして自分で書かせてくれます Proactive: 判断で立ち止まらず、実行を優先します それぞれの性格とか向き不向きは公式のドキュメントによくまとまってるので、そちらに譲ります。 ずっと同じスタイルで使いたいなら、設定に書いて保存します たぶんこれが一番よくやる形だと思います。 自作のスタイルファイルの置き場は3段階あります。自分のPC全体に効かせるなら ~/.claude/output-styles/ 、このリポジトリだけに効かせるなら .claude/output-styles/ 、あとは組織が配るmanaged policyです。中身はfrontmatterに name ・ description ・ keep-coding-instructions があれば動きます(実物は記事の最後に丸ごと貼ってあります)。 置いたスタイルは /config から選べて、選んだ結果は .claude/settings.local.json に保存されて、そのまま効き続けます。ただしこれ、公式に local project level と明記されているとおり project 側の設定なので、自分のPC全体には効きません。全部のプロジェクトで効かせたいなら ~/.claude/settings.json に outputStyle を自分で書く必要があります。中身はこれだけです。 { "outputStyle": "dialogue" } 効くタイミングについては、公式にはっきり書いてあります。 Output style is part of the system prompt, which Claude Code reads once at session start. Changes take effect after /clear or a new session. 訳すと「output styleはシステムプロンプトの一部で、Claude Codeはセッション開始時に一度だけ読み込む。変更を効かせるには /clear するか新しいセッションを始める必要がある」ですね。走ってる途中で書き換えても、そのセッションには反映されないってことです。 で、ここまでが「保存して、ずっとそれで使う」やり方なんですけど。この形だと 全部の会話が同じスタイルになる んですよね。僕がやりたかったのは、そこじゃなかったんです。 作業ごとに変えたいので、僕は起動をやり直す方を選びました さっきの公式の引用のとおり、 /clear でも切り替わります。なので同じセッションのまま /config で選び直して /clear 、という運用でも全然いけます。 ただ僕は作業ごとにペインを新しく立てるタイプなので、どうせ起動するなら起動時に渡してしまえ、という方を選びました。そのためのコマンドがこれです。 claude --settings '{"outputStyle":"structured"}' さすがに毎回JSONを書くって運用はだるいので、スクリプトでラッパして起動時に選択するだけでoutput styleを選択できるようにしています。 structured は自分で書いたスタイルの名前で、中身は次の章で出てきます。 面白いのは、 outputStyle ってキー自体はさっきの settings.json とまったく同じところです。設定ファイルに書けば恒久設定になって、起動時に --settings で渡せばその起動限りの一時設定になる。 同じキーを、置き場所だけで恒久にも一時にもできる のが、この運用の芯でした。 じゃあ保存済みの設定と --settings が両方あったらどっちが勝つのか。ここは公式に書いてなかったので自分で試したんですけど、 --settings のほうが勝ちます。しかも設定ファイル自体は書き換わらなくて、効くのはその起動限りでした。 /config で普段のスタイルを決めておいて、特定の作業のときだけ起動時に上書きする、が素直に成立します。 あとはClaude Codeに新規ペインを作らせるって運用もあります。これはtmux経由でClaude Codeを起動する必要があるんで、環境の用意は要りますね。 tmux側でペインを増やす話は前に書いたので、ここでは起動の一行だけにしておきます。tmuxの入れ方は DevContainer に tmux を入れて、設定ごと配る 、その場でペインを増やす運用は Claude Codeをスマホでリモート、その場でペインが増える にまとめてあります。ホスト側の ~/.claude をどう扱ってるかも Claude Codeの~/.claude、どれを共有してどれを分ける? に書いたので、そちらで。 あと公式に書いてあることをひとつだけ。output styleはsubagentには適用されません(forkのときだけ例外だそうです)。 自分で書いた文言は、ちゃんと形になって出てきました ここからは実験です。実際どんだけ返信が変わるんかな、って気になったので。自分で書いた2枚と、何も置いていない既定の3条件に、同じ5問を各1回ずつ投げて、返ってきたものを数えました。モデルはOpusで固定しています。 書いた2枚はこういう性格です。 dialogue : 「散文で答えろ」という1枚。「つながった文で答えて、箇条書きや表は本当に構造が要るときだけ使え」と書きました structured : 「短い塊に刻め」という1枚。「まず結果を1文で出して、そのあとは1点1項目の短いブロックに割れ」と書きました 体感で語ると絶対に盛るので、確かめ方は少し工夫しました。まず「本当にスタイルが効いているか」自体を、乱数で作ったトークンを返信の末尾に必ず付けろという指示で二値判定します(トークンはシェル側で作って自分には見せていないので、システムプロンプト経由でしか出てきません)。そのうえで、1問1条件ごとに空っぽの使い捨てディレクトリを作って、その回に使うスタイルを1枚だけ置いて投げました。とはいえ、質問の内容自体が答えの形に効いてくる交絡はどうやっても残ります。 数えた結果がこれです。 既定(基準) dialogue structured 字数(5問平均) 1582字 843字(既定比53%) 1011字(64%) 行数(5問平均) 47.0行 9.6行 22.6行 箇条書き(5問合計) 18 0 38 どれも5問の平均です。既定は質問によって振れて、今回の5問でも一番長い問い(2108字)と一番短い問い(998字)で2倍の開きがありました。平均を絶対値みたいに扱うと事故ります。 数字だけだとピンとこないので、実物も1組だけ置いておきます。「小規模なチームで、モノレポにするかリポジトリを分けるか迷っています。どちらがいいですか?」に対する、既定と dialogue の返答です。どちらも長いので冒頭だけの抜粋です。 既定はこうでした。 小規模チームなら、まずモノレポをおすすめします。理由は「人数が少ないほど、リポジトリを 分けるコストが相対的に重くなる」からです。 ## モノレポを推す理由 **横断変更が1PRで済む** APIのスキーマを変えたらフロントも直す、という変更が日常的に起きます。(略) **環境構築とCIが一元化できる** lint設定、フォーマッタ、tsconfig、CIワークフロー、依存バージョン。分けると必ずドリフトします。 ## 分けたほうがいい場合 - **公開ライブラリ/OSS** — 外部にリリースするパッケージは、独立した履歴とIssueトラッカーが あったほうが素直です - **ライフサイクルが本当に無関係** — 社内ツールや実験用プロジェクトなど、本体と一緒に ビルドする意味がないもの (以下略) dialogue はこうです。 小規模チームであれば、まずモノレポをおすすめします。 理由は単純で、リポジトリを分けたときのコストの大半が「人数が少ないほど重くのしかかる」 種類のものだからです。共通ライブラリのバージョン管理、複数リポジトリにまたがる変更の PR調整、CI設定の重複、どこに何があるか分からなくなる問題――これらはチームが小さいほど、 一人あたりの負担として効いてきます。(略) 分けたほうがいいのは、次のどれかに当てはまるときです。デプロイ先も言語もライフサイクルも まったく別物で、コードを共有する見込みがない場合。外部に公開する OSS やクライアントに 渡す成果物が含まれる場合。あるいは、アクセス権限を明確に分離する必要がある場合です。 結論の中身はどっちも同じです。変わったのは渡され方のほうでした。 一番はっきり出たのが箇条書きの行です。 dialogue は5問通してゼロ、 structured は既定の倍以上刻んでます。「散文で答えろ」と書いた1枚が箇条書きを使わなくなって、「短い塊に刻め」と書いた1枚が刻む。 書いた文言が、そのまま形になって出てきてる わけです。ここは素直に面白かったです。 で、もうひとつ。よく見ると2枚とも既定より短いんですよね。片方は散文で答えろとしか書いてないのに、です。種を明かすと、2枚には共通で書いた1行があります。 Keep responses focused, brief, and concise. 短くなったのはこの1行のぶんで、散文で答えろと書いたからではなかったです。 ここまで見えたのは、あくまで出てきた形の話です。中身の考え方まで変わったのかは、この測り方では見えていません。 それと、です・ます調まわりの日本語の文体は公式がカバーしてる範囲の外なので、そこは完全に自作で割り切って書いてます。 受け取る形だけは、自分で揃えられるんだなと思いました 触ってみて思ったのは、 こっちが受け取る形は自分で揃えられる んだな、ということでした。作業ごとに「この時はこう答えて」って毎回説明し直してたのが、起動時の一行に置き換わったのは地味に嬉しいです。 とはいえ各条件1回ずつしか測ってないので、この形が毎回同じように出るとまでは言い切れません。 あと、output styleがAIの思考そのものに影響するのかどうかは、僕は確かめていません。同じことを疑った人は前からいるみたいで、GitHubにissueも立ってるんですが、誰も答えないまま自動で閉じられてました。公式は「どう答えるかを変えるだけで、何を知っているかは変えない」と言っているので、一旦はそちらに乗っかっておきます。そもそも仮に思考の側に差があったとして、それがoutput styleのおかげなのかモデル側の性質なのかも、この測り方だと切り分けられてないですしね。 ではまた! 付録:今回使ったoutput styleの全文 dialogue 。 --- name: dialogue description: 会話モード。散文で答え、リストは必要なときだけ使う keep-coding-instructions: true --- Keep responses focused, brief, and concise. Keep disclaimers and caveats short, and spend most of the response on the main answer. When asked to explain something, give a high-level summary unless an in-depth explanation is specifically requested. Answer in prose, in connected sentences. Use bullets or a table only when the content is genuinely multi-dimensional and the structure is what makes it clear. If you have to choose between short and clear, choose clear. 日本語で答えるときは、です・ます調で書く。 structured 。 --- name: structured description: 刻むモード。結果を1文で出し、以降を短い塊に分けて読ませる keep-coding-instructions: true --- Open with the outcome: one sentence on what happened or what the answer is, before any detail. Keep responses focused, brief, and concise. Keep disclaimers and caveats short, and spend most of the response on the main answer. When asked to explain something, give a high-level summary unless an in-depth explanation is specifically requested. Break what follows into short blocks: one bullet per point, and a heading when there are several groups of them. Keep each block to a few lines. If you have to choose between short and clear, choose clear. 日本語で答えるときは、です・ます調で書く。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Codeの返信が長い、output styleを自作して測ってみた first appeared on SIOS Tech Lab .
Claude Codeのskill、気づいたら結構な数になってました .claude/skills/ に増えていくskillって、たとえるなら荷物なんですよね。今日は docs/article/ しか触らないのに、 docs/seminar/ 用も application/ 用も、起動した瞬間から肩に乗っている。増えること自体は悪いことじゃなくて、困りごとのたびに1枚足すのは健全な運用だと思います。 ただ、本当に効いてくるのは重さじゃないんですよね。ある領域専用のskillって、作った直後は使うんですが、しばらくすると触らなくなるじゃないですか。「動いてるからいいや」で放置して、気づいたら中身がどうなっているかも把握できていない。実害はこっちのメンテの腐りの方だと思っています。 とはいえ僕自身、しんどいと思っていたわけでもないんですよね。リポジトリの特性上しかたないと諦めていました。で、これ捨てるしかないと思っていたんですが、そうでもなかったんですよね。 ところが、これ捨てなくても減らせるんですよね やっていることは、置き場を1階層ずらしただけです。 リポジトリ/ ├─ .claude/skills/ ← ここは常駐。起動した時点で全部乗る └─ docs/article/ └─ .claude/skills/ ← ここに置くと、docs/article/ を触るまで乗らない 下に移したskillは、セッションを起動した直後には1個も見えてこないんですよね。ところが、 docs/article/ 配下のファイルを触った瞬間、こういう通知が来ます。 驚いたのは、知らないskillが急に現れたことじゃないんですよね。skillは自分で書いているので、存在自体は最初から知っています。驚いたのは、仕組みが実際にちゃんと効いていたことです。消してもいないし、起動した瞬間から肩に乗る状態(常駐)にもしていない。触った瞬間だけ、そこにいる。 減らすっていうのは、捨てることじゃないんですよね 上が、ディレクトリを分けずに全部rootへ置いた場合です。 ある領域でしか関係のないskillを、まったく別の作業にまで持ち込んでいる 状態なんですよね。セミナーの企画を練っている最中に、ブログのレビュー用skillが肩に乗っている。下がそれをやめた状態で、人は上下で同じまま、持ち物の置き場だけが変わっています。捨てたわけじゃなくて、置き場を変えただけなんですよね。 たとえ話で行くなら、山登りに行くのにデスクワーク用の道具を持っていくみたいなイメージですね。さすがに過酷な山登りにPCをもっていってブログを書くなんてことしないですよね…ちゃんとおうちに置いていきましょう。 これ、自分で見つけた裏技とかじゃなくて、Claude Codeの公式ドキュメントにちゃんと書いてある仕組みです( Agent Skills )。動的にロードされる設計が、もともと用意されているという話なんですよね。 しかもこれ、前の章で言った「触らなくなって腐る」問題にも効くんですよね。触らない領域のものは、腐っていたとしてもそもそも肩に乗っていません。そして道具が作業のすぐ隣に置いてあるので、その領域を触っている日は、skillのメンテもその作業の射程に入ってくる。別の場所へ見に行く必要がないぶん、手が入りやすいんですよね。 ちなみにCLAUDE.mdは、この仕組みで外れる側じゃなくて、捨てる側で運用しています。刈り込みは2回効きましたが、3回目はまだ来ていません。捨てる側はこうやって止まるという対比だけ、今日は取っておきます。 自分のskillは、どこに置けばいいのか考えました 僕は開発も調査もブログもセミナーも、ぜんぶ1つのリポジトリに寄せて進めています( Claude Codeで全業務を1リポジトリに一元管理する作業基盤の作り方 )。だから「ディレクトリごとに担当が違う」という状態が最初からあって、今日の話もそこに乗っています。 ディレクトリをどこで切るかという一般論は、公式ガイドが1ページ使って書いているので、そちらに譲ります。ここで決めたいのは置き場だけです。 置き場を決める軸は、誰がそのskillを撃つかです。 モデルが撃つskill 人だけが /名前 で撃つskill 置き場所 担当ディレクトリのスコープへ移す rootに据え置いたまま 減るもの 常駐コスト(description) / 候補一覧の汚れだけ ここでいうスコープは、さっきの「そのディレクトリを触ったら出てくる」の仕組みのことです。これと disable-model-invocation (長いので以下dmi)は、名前が並びがちですが別の道具なんですよね。スコープは常駐コストを外す道具で、dmiは誰が撃てるかを制限する道具なので、混ぜて考えないようにしています。 図の右側が、常駐する文字数の内訳です。2026-08-29時点の実測で、数えているのはトークン数ではなく、起動時に注入されるdescriptionの文字数だけです。 見てのとおり、 docs/article/ を触るだけで常駐の4分の1くらいが上乗せされます。ブログを書く日だけ乗って、セミナーの日には乗らない。この差が、置き場を分けたぶんです。 余談ですが、以前僕自身が別の記事で「特定の作業・ディレクトリでしか使わないskillはdmiで外す」と書いたことがあるんですが、これはもう今の正解じゃないので訂正しておきます。 実際に置き場を変えてから、作業のしかたが1つだけ変わりました 移設したあとにやることは、実はそんなに難しくないんですよね。その領域の作業を始めるとき、1発目をこう投げるだけです。 @docs/article/CLAUDE.md ブログの話をしようか これで通知が出るので、乗ったのを見てから本題に入る。それだけです。この確認は1セッションにつき1回で十分で、一度発見が走ったディレクトリは、そのセッション中はもう見に行かなくていいんですよね。 冒頭で、使わない道具まで全部持ち歩いているのが荷物みたいだ、という話をしました。置き場を変えたあとの毎日は、まさにその逆です。今日触る領域の道具だけが手元に出てきて、触らない領域の道具は棚に戻ったまま出てこない。捨てたわけじゃなくて、必要なときにだけ持ってくるようになっただけなんですよね。 で、これでどれくらい軽くなったかというと、2026-08-29時点の実測で、全部が常駐だったころの9,170字から、いまは6,789字まで落ちています。率にすると-26.0%です。 正直、これは自分で自分の数字を殴っておきたいところで、世の中には-83%減らしたみたいな先行事例もあるので、それに比べると3倍以上の差があります。それに、文字数が減ったからってエージェントが賢くなったわけでもないんですよね。減ったのはあくまで肩の荷物の重さで、それ以上でも以下でもない、というのが正直なところです。 ではまた! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post Claude Codeのskill、捨てる前に置き場を変えてみませんか first appeared on SIOS Tech Lab .