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

TECH PLAY

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

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

全737件

こんにちは。サイオステクノロジー OSS サポート担当 山本です。 今回のお題は「TimescaleDB」です。 まずはこれがどんなものなのか、という基本のところからお話ししてみようと思います。 ■TimescaleDB って何だ? TimescaleDB は、 Tiger Data 社 (旧名:Timescale 社) によって開発された PostgreSQL の拡張機能 です。 “ 時系列データベース ” と呼ばれるカテゴリのデータベースであり、” 列指向データベース ” の技術を活用した強力な機能を提供してくれます。 ■列指向データベース・時系列データベースって何だ? 列指向データベース と 時系列データベース は、結論から言ってしまうと、いずれも 大規模なデータの集計・分析 に 特化 したデータベースです。 順を追って確認していきましょう。 ■そもそもデータベースって何だっけ? 単に「データベース」と言う場合、長く広く利用されている関係性データベース管理システム ( RDBMS / Relational DataBase Management System) を指すケースがほとんどでしょう。 まずはこちらをざっくり確認します。 RDBMS は、雑に言うと 一定の構造にまとめたデータの集まり を保存し、読み出しや検索・集計、データの修正・削除などを行える、極めて汎用性の高いシステムです。 用途は多岐にわたりますが、例えばある商店が “顧客情報” “商品情報” “販売履歴” を管理したい…と考えた場合、例えばこんな感じで利用できます。 (正規化しろ、と怒られるかもしれませんが、ここではイメージ優先ということでご容赦を…!) ■列指向データベースって? 列指向データベース は、前述の RDBMS の後に生まれた概念です。 先ほどの RDBMS のイメージ例を見ていただくとわかるとおり、従来の RDBMS では 1行で一つのデータのセット を表現しています。 内部的にも基本は行単位でデータを保存しているため、 列方向 での合計や平均値の計算といった 集計処理 は(その列の情報のみをピンポイントで抽出できないため)少し苦手としています。 大抵の場合は問題にならないのですが、超大規模なデータを使って集計・分析を行う場合……例えば 監視系のシステム や、純粋に 分析目的の超多量のデータを扱うシステム などでは、規模次第ではこの性質がネックになります。 ……それなら、内部的に(RDBMS で言うところの) 列方向でデータがまとまるように保存すれば、集計処理に有利になる のでは? という考え方を実装したのが、 列指向データベース です。 このため、このカテゴリのデータベースは 超大規模データの集計・分析向け とされるものが多く、この点において RDBMS より優位に立ちます。 また、集計処理向けのデータを 列方向に見た場合 、 同じ形式で似たような値が連続する ケースが少なくないため、 様々なデータ圧縮技術を適用しやすい という副次効果もあります。 今回扱う TimescaleDB では、複数の圧縮技術を活用することで、通常の PostgreSQL としてデータを保持するよりも データ容量を最大 95% ほど削減できる とされています。 参考: Time-Series Compression Algorithms, Explained – Tiger Data ただし、列指向データベースは逆に行方向での処理、特に 登録済みデータの個別編集などを極めて苦手としている ため、万能な選択肢というわけではありません。 ・データ規模が著しく大きく ・データの蓄積・集計処理を目的としていて ・登録したデータを個別に変更することが基本的にはない といった条件を満たす、限られたケースでのみ輝くカテゴリと言えるでしょう。 ■時系列データベースって? 時系列データベース もまた、RDBMS の後に生まれた概念です。 こちらは名前から推測できるとおり、保存するデータに含まれる “時間” の要素を軸にしてデータを整理 しよう、という構想のデータベースです。 これもやはり、 超大規模データへの対応 を目的としています。 “時間” の要素がある大規模データの代表例として、監視システムについて考えてみましょう。 監視システムでは定期的にデータを収集してデータベースへ書き込んでいきますが、運用期間が長くなるほどデータは蓄積し、いずれ容量不足を引き起こします。 そのため、一定以上古くなったデータを削除することで容量問題を対策する、というのが定石です。 しかし、 RDBMS は大規模なデータにおける選択的な削除 (DELETE 文) の処理があまり得意ではありません 。 また、 すでに大規模なデータが保存されている状態での継続的なデータの追加も、徐々にパフォーマンスが落ちる 傾向があります。 このように、 定期的な大規模データの書き込みと削除を繰り返すシステム を運用する場合、RDBMS がボトルネックになる可能性があります。 この 大規模なデータの継続的な書き込みや、効率的なデータ削除に対応 することを目指したのが、 時系列データベース です。 目標や着眼点はある程度共通していますが、実際のアプローチ方法はソフトウェアごとにまちまちなようです。 ■で、結局 TimescaleDB ってどういうものなの? さて、それでは改めて TimescaleDB がどういうものなのかを見てみましょう。 まず、これは RDBMS である PostgreSQL の拡張機能 です。 この拡張機能を有効にしたデータベース上で、table (※) の代わりとなる hypertable というものを作ることで、機能を利用できるようになります。 ユーザからは、この hypertable は普通の table とまったく同じように見えます 。 ※ table とは「どんな列を持つデータを保存するのか」を定義した入れ物のようなものです。 → つまり、 hypertable を作成する定義の部分を除けば、ほぼ通常の PostgreSQL の操作感で使うことができます。 一方で内部的には、 hypertable は時間軸などを基準にして “ chunk ” と呼ばれる小さな table を継続的に自動作成し、それらを束ねて参照することで「一つの大きなテーブル」に見せかける……ということをしています。 chunk への書き込みは RDBMS である PostgreSQL の標準どおり「行単位」で実行されますが、 設定した規定の時間が過ぎた古い chunk は、 列指向 の考え方を基にデータの組み替え・圧縮 が行われます。 → これにより、集計処理に強くなり、データ容量も大幅に削減できます。 そして、古くなり不要になったデータは、予め設定しておけば自動的に chunk (小さなテーブル)単位で丸ごと破棄されます。 → これにより、個別の削除 (DELETE) よりも効率的かつ高速にデータの破棄ができます。 つまり、TimescaleDB とは ・PostgreSQL というメジャーな RDBMS 上で ・大規模データの保存・集計処理・データ破棄を極めて効率的に行う ための拡張機能、と言えるでしょう。 また、ベースが PostgreSQL なので ・標準的な SQL 文がほぼそのまま使用できる (独自の文法の習得がほぼ不要) ・通常の RDBMS と時系列データベースの双方の機能を 1つのソフトウェアで完結できる というメリットも存在しています。 ■最後に 今回は PostgreSQL の拡張 TimescaleDB がどんなものなのかについてお話ししました。 ざっくりと概要が伝わっていれば幸いです。 これ自体の用途はある程度限定されますが、汎用的な RDB である PostgreSQL の拡張であるため、PostgreSQL の元々の機能と組むことで独自の特異なカバー範囲を見せられるのは明確な強みと言えるでしょう。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post 時系列データベース「TimescaleDB」って何なんだ? first appeared on SIOS Tech Lab .
AI を活用した開発が主流となる中、生成AIコードに潜むライセンス違反リスクが大きな課題となりつつあります。 AI が自動で OSS ライブラリを選定、組み込むようなケースだけでなく、既存 OSS のコードスニペットを生成する可能性もあり、従来の人の目に依るレビューでは把握しきれないという新たなリスクが生まれています そこでライセンス違反リスクを低減する SCA ツール である SCANOSS の機能や活用方法をお伝えします。 <日時>  2026/10/22(木)16:00 〜 16:45   <費用>   無料   <視聴方法>   Zoom   <詳細・お申し込みはこちらから> https://tech-lab.connpass.com/event/406198/ The post 【10/22ウェビナー】意図せず AI が OSS を出力する?著作権やライセンス違反リスクを低減する SCA について first appeared on SIOS Tech Lab .
自己紹介とインターン参加理由   自己紹介 はじめまして、金井南海です。東京電機大学 理工学部 理工学科 理学系に所属する大学 3 年生です。 専攻では画像処理や機械学習理論といった数理情報系の学問を学んでいて、数式やアルゴリズムの仕組みを理解しながらものを作ることに面白さを感じています。   プライベートでは、ダーツと映画鑑賞が趣味です。 ダーツは狙った場所に投げる集中力と、思い通りにいかないときの試行錯誤が楽しく、映画鑑賞はストーリーの構成や伏線の張り方に注目して観るのが好きです。 なぜ数ある企業からここ(サイオス)のインターンシップを選んだのか サイオスのインターンシップを知ったのは、大学の学内ナビサイトがきっかけでした。数あるインターンの中で目を引いたのは、自分が興味を持っていた 生成AIについての勉強会 が用意されていたことです。 また、これまでの自分の開発経験は個人開発だけで、 チームで1つのプロダクトを作り上げる経験 がありませんでした。せっかくなら、その未経験の部分にこの 10 日間で挑戦してみたいと思い、応募を決めました。 インターンシップでの成果物 成果物の紹介 私たちのチームが作ったのは、就活生の情報を 1 か所に集める就活管理ダッシュボード 「ShukatsuHub」 です。 私自身もそうですが、就活生は複数のメールアドレス・求人サイト・カレンダー・自己分析シートをバラバラに管理している人が多く、情報の見逃しや転記の手間に困っています。 ShukatsuHub は、ダッシュボード・カレンダー・メール受信箱・履歴書用のプロフィール管理・想定問答・選考ステータス管理・企業マッチング・企業研究・ナレッジ蓄積・メール通知という機能を 1 つのアプリにまとめ、 就活にかかる手間を減らして浮いた時間を研究や休息に充てられるようにすることを目指しました。 自分の役割 私は主に、 ①複数のメールアカウントを自動で分類する 「受信箱」機能 ②重要なメールに気づける 「通知」機能 ③面接の振り返りや企業メモを残す 「ナレッジ蓄積」機能 を担当しました。 開発の概要と学び 今回のハッカソンは、 7 日目に中間発表、最終日の 10 日目に成果発表という非常に過密なスケジュールでした。本格的な実装を始めたのは 4 日目からで、それまではチーム全員で要件定義や機能選定に時間を費やしていました。残りの時間を考えると焦りもありましたが、 Claude Codeを活用することで短時間での実装が可能 になりました。 最終的に、事前準備を丁寧に行ったことで大きな手戻りもなく、完成度の高い機能を形にすることができました。時間がないときこそ 「何を作るか」を固める時間 を惜しまないことが、開発の 1 番の近道だと学びました。 また、今回は初めてのチーム開発で、 GitHub を使うのも初体験でした。開発中に「実装の重複」が起きるトラブルを経験したことで、 「作業前に最新のmainを確認する」「早めに小さくpushする」 といった、チーム開発ならではの立ち回り方を習得できました。 個人開発では意識しなかった視点やノウハウを多く得られ、企画・技術の両面で大きな成長を感じられた貴重な機会となりました。 成果発表の感想・気づき 成果発表では準備不足を痛感しました。スライドやデモは作り込んだものの、本番の緊張でペース配分が乱れ、魅力を伝えきれませんでした。準備の完成度と本番で発揮する力は別物なのだと実感しました。 発表後、社員の方々から「先にデモを見せて概要から話す」「最初に解決する課題を伝える」といったアドバイスをいただき、 聞き手視点の意識が不足 していたことに気づかされました。 時間配分を含めたリハーサルの重要性 も大きな学びです。 それでも、技術的な挑戦や UI/UX 、機能動作については高い評価をいただけたのが大きな自信になりました。今後は 「遊び心やオリジナリティ」 も意識しつつ、伝える力も含めてさらに磨いていきたいです。 チーム開発を通じて気づいたこと、学んだこと 開発初期に作業の重複(ブッキング)が起きたことをきっかけに、自分が今どの機能・どのファイルを触っているかを 着手前に一声かけ、進捗をこまめに共有する よう意識しました。この小さな積み重ねが、作業の被りやトラブルを防ぐことにつながりました。 この 10 日間、共に挑んだメンバー 2 人の存在は非常に心強かったです。 1 人は、 Gmail 連携のつまずきそうなポイントや対処法をあらかじめ手順書としてまとめてくれ、詰まった箇所もスムーズに解決できました。実装した人にしか分からない細かい注意点まで言語化して残す姿勢には、本当に感心しました。 もう 1 人は、主要機能の開発を数多く担いながら、最終発表が近づいてからはスライドの内容更新や発表原稿の作成まで行ってくれました。 3 人の発表時間が均等になるよう文字数で分担を配慮してくれるなど、細やかな気配りに何度も助けられました。 チーム全体を通しても、失敗や詰まったことを隠さずに共有し「なぜ起きたか」「どう防ぐか」を議論する活発な雰囲気がありました。 「失敗を早く共有した人が評価される」空気感 があったからこそ、安心して開発を進められたと感じています。 インターンを通しての最大の学び インターンシップに参加する前は、エンジニアという仕事は何よりもまず技術力が第一だと思っていました。しかし、このインターンシップを通して、 技術力と同じくらい、あるいはそれ以上に「コミュニケーション」が大切なのだと実感しました。 どれだけ良い機能を実装できても、チームに進捗や意図が伝わっていなければ作業が重複したり、仕様と実装がずれたりします。逆に、こまめな共有や報連相があるからこそ、それぞれの技術力が本当の意味でチームの成果につながるのだと感じました。 今後の目標 このインターンシップで学んだ「技術力だけでなく、コミュニケーションや報連相を大切にする」という姿勢を、今後の大学生活や研究にも活かしていきたいです。例えば専攻している画像処理や機械学習の研究でも、1人で完結させるのではなく、 周囲と積極的に議論しながら進めること を意識したいと思います。 また、今回のインターンでチーム開発や Git を使った開発を初めて経験できたので、今後は個人開発だけでなく、 チームでプロダクトを作る機会 にも積極的に挑戦していきたいです。今回興味を持って参加した生成 AI についても、このインターンでの学びを土台に、もっと深く学んでいきたいと考えています。 これからインターンを受ける後輩へ向けたアドバイスや応援の一言! もし今、技術力に自信が無くて不安に思っていても、心配しすぎなくて大丈夫です。私自身、チーム開発も Git も今回が初めてでしたが、実際に手を動かしていくうちに少しずつ理解できるようになりました。それよりも大事なのは、 分からないことや詰まったことを1人で抱え込まず、早めにチームに共有すること だと思います。 また、開発期間が短いからといって、いきなり手を動かしたくなる気持ちはまず抑えて、 要件定義や機能選定にしっかり時間をかけてください 。「急がば回れ」で、土台を固めておいたほうが、結果的に良いものが作れます。短い期間ですが、得られる学びはとても大きいので、ぜひ楽しんで挑戦してみてください! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 2人がこの投稿は役に立ったと言っています。 The post 初めてのチーム開発で学んだ「コミュニケーション」の大切さ(サイオスインターンシップ2026【1期Aチーム】) first appeared on SIOS Tech Lab .
こんにちは、サイオステクノロジーの佐藤 陽です。 今回は、ブラウザで音楽を鳴らせる「Strudel」と Azure を組み合わせて、 Azure のインフラの状態を音楽にしてみた 、という話を書いていこうと思います。 実務でバリバリ使うような仕組みではなく、完全に「作ってみた」系のネタアプリです。 肩の力を抜いて、是非最後までご覧ください! はじめに 事の発端は、先日参加した技術イベントです。 ほかの登壇者の方の発表で、 Strudel  というものの存在を知りました。 画面にはソースコードっぽいテキストが並んでいて、それを書き換えると、その場で音楽が変わっていました。 「こんなコーディングのノリで音楽鳴らせるの面白いなぁ」と、しばらく見入ってしまいました。 そしてふと「テキストで音楽を鳴らせるということは、プログラムから生成したテキストでも鳴らせるわけで、 色々応用できるんじゃない?  」 と思いました。 たとえば、Azure のインフラの状態を音にして、CPU がパンパンに圧迫されているときは深刻な音楽が流れてくる、とか面白そうだな。 …と思いついたので、作ってみました! デモ動画 ※デモ用の音楽であり、実際のAzure環境を反映しているわけではありません ※音量注意 https://tech-lab.sios.jp/wp-content/uploads/2026/10/Screen-Recording-2026-09-28-10.22.mp4 サンプルコード 今回作ったものは、 GitHub に公開しています。 DevContainer で開けばすぐに開発環境が整うようにしてありますので、気になった方は是非触ってみてください。 https://github.com/satodayo/sonification Strudel とは そもそもStrudel は、ブラウザで動くライブコーディング用の音楽環境です。 TidalCycles という音楽用の言語を JavaScript に移植したもので、コードを書くとすぐにその場で音が鳴ります。 たとえば、こんな 1 行を書くと n("0 2 4 2 5 4 2 1").scale("C4:major").s("triangle") ハ長調のメロディが、三角波の音で鳴ります。 また、Strudel は結局のところ npm のパッケージ ( @strudel/web など)です。 音は Web Audio API を使ってブラウザの中で合成しているので、サーバー側には音声ファイルも音声処理も一切ありません。 つまり、普通の Web ページに script タグで読み込むだけで、自分のページから自由に音楽を鳴らせます。 何を作ったのか Azure 上のアプリの状態に合わせて、ブラウザで流れている曲が変わる Web ページを作りました。 CPU 使用率 が上がると、テンポが速くなる(66〜156 BPM くらい) レスポンスタイム が悪化すると、音がこもっていく CPU が一定ラインを超えると、同じメロディのまま 曲調が暗転する 平常時はオルゴールっぽい穏やかな曲が流れていて、負荷をかけていくとだんだん速く、こもった音になっていきます。 コミカル版もあります(羊入り) 作っているうちに、シリアスな曲だけだと動画映えしないなと思い始めて、コミカル版も作りました。 画面のボタンで切り替えられます。 平常時は、ぴょこぴょこしたチップチューンに「ブンチャッ、ブンチャッ」のベース。 そこに 羊が「メェ〜」と鳴きます 。 羊の鳴き声は Strudel から使えるサンプル音源に最初から 7 種類も入っていたので、全部順番に鳴らしています。 負荷が上がってくると、メロディの音程が「ぷぇ〜」と情けなく垂れ下がり、「ぷわ〜ぷわ〜ぷわ〜ぷわわ〜ん」の残念トロンボーンが流れ始めます。 そして 羊も慌てはじめ、甲高い声でランダムに鳴き出します。 https://tech-lab.sios.jp/wp-content/uploads/2026/10/Screen-Recording-2026-09-28-10.23.mp4 アーキテクチャ 構成は以下のようになっています。 登場するリソースは、App Service、Azure Monitor、Azure Functions、Blob Storage の 4 つだけです。 それぞれの役割を簡単に紹介します。 Web ページは Blob Storage の静的 Web サイトで公開 HTML は 1 枚だけで、Blob Storage の静的 Web サイト機能で公開しています。 先ほど書いた通り音はブラウザの中で鳴るので、サーバーを立てる必要がありません。 実際のファイルの中身は こちら を参照ください。 パラメータは JSON ファイルで渡す Blobコンテナ内のHTMLファイルと同じ場所に metrics.json という小さな JSON ファイルを置いています。 { "cpu": 0.78, "latency": 1, "errors": 0 } ブラウザはこのファイルを数秒ごとに読みに行き、中の値に合わせてテンポやスケールを切り替えます。 このJSON ファイルを書き換えれば曲が変わる、というだけのシンプルな仕組みです。 負荷をかける対象の App Service 音にする対象として、App Service に小さなアプリを置いています。 特定の URL を叩くと CPU を空回りさせるだけのアプリで、デモのときはこれで意図的に負荷を上げます。 Functions で定期的にメトリクスを取得 Azure Functions がタイマーで 30 秒ごとに動き、Azure Monitor から App Service の CPU 使用率とレスポンスタイムを取得します。 取得した値を 0〜1 に正規化して、Blob 上の metrics.json を書き換えます。 あとはブラウザが新しい値を拾って、勝手に曲が変わっていく、という流れです。 実際に動かしてみての感想 負荷をかけてから曲が変わるまでに、 3〜4 分 かかりました。 最初は Functions やブラウザの取得間隔を詰めればいけるかな?と思ったのですが、測ってみたら遅延の大半は Azure Monitor 側でした。 CPU のメトリクスは、負荷をかけてから 2 分ほど経ってようやく現れます。 また曲の設定についてですが、最初は、平常時と異常時で別の曲に切り替えようとしていました。 ですが、同じメロディのままスケールだけ暗くしたほうが、圧倒的に不穏に聴こえました。 さっきまで聴いていた曲がおかしくなっていくほうが、「あ、何か起きてる」と感じるような気がします。 おわりに 今回は、Strudel と Azure を組み合わせて、インフラの状態を音楽にしてみました。 ダッシュボードは見ていないと異常に気づけませんが、音なら別の作業をしていても耳に入ってきます。 ……というもっともらしい理由もありますが、正直なところ「やってみたら面白そう」が 9 割です。 JSON 1 個で曲を操れるので、インフラに限らず、天気や株価、Slack の未読数など、何でも音にできそうです。 まだまだ面白い事ができそうなので、是非遊んでみてください! ではまた! ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 1人がこの投稿は役に立ったと言っています。 The post Azureインフラの音を聴け!― Strudel でインフラの状態を音楽にしてみた first appeared on SIOS Tech Lab .
武井でございます。 手元のボタンを見るだけで、どの Claude Code が作業中で、どれが確認待ちで、どれが終わったのかが一目で分かる。 ボタンを押せば、そのターミナルに一発で飛べる。 作業が終わったら、ずんだもんが「何をやって、次に何をすればいいか」を声で教えてくれる。 そんな作業環境を Stream Deck と herdr 、 ローカルLLM 、そして VOICEVOX(ずんだもん) で作りました。まずは動いているところを見てください。 https://tech-lab.sios.jp/wp-content/uploads/2026/09/movie-edit.mp4 この環境でできることは、次の 3 つです。 全タブの状態がボタンで分かる :ボタンの色と、ボタンに住んでいるドット絵のキャラクターの動きで、Claude Codeのタスクの作業中・確認待ち・完了が分かる ボタンを押すとそのタブに飛べる :ターミナルをたくさん開いていても、ボタン一つでそのターミナルにとべる。またターミナルが別のデスクトップにあっても、そこへ移動してタブまで切り替わる 終わったらずんだもんが教えてくれる :回答が終わったときや確認待ちになったときに、「何をしたか・どうなったか・次に何をすればいいか」をずんだもんが読み上げる 以下、なぜ作ったのか、どういう仕組みなのかを順に紹介します。 なぜ作ったのか AI エージェントで開発するようになってから、ターミナルを開く数が一気に増えました。Claude Code に 1 つのタスクを任せている間に、別のターミナルで別のタスクを任せる。気づけば 5 個、10 個とターミナルが並んでいます。まぁ、以下はちょっと大げさですが。。。   こうなると、困ることが 2 つ出てきます。 どれがどういう状態か分からない :どのターミナルが確認待ちで止まっていて、どれが終わったのか、1 つずつ見に行かないと分からない。確認待ちのまま何十分も放置していた、ということがよく起きる ターミナルの行き来が面倒 :ウィンドウやタブを探して切り替えるのが地味に手間。別のデスクトップに置いていると、さらに面倒 これを、手元のデバイスと音声で解決しようというのが今回の仕組みです。 全体像 登場人物は次の 5 つです。   Stream Deck のプラグインが真ん中にいて、herdr の状態を見張っています。図の番号に沿って、処理の流れを説明します。 ① ユーザーが herdr のターミナルで、Claude Code に作業を依頼します。 ② herdr の中で動いている Claude Code が、Claude とやり取りしながら作業を進めます。 ③ Stream Deck プラグインが、1.2 秒ごとに herdr の CLI を呼んで、スペース・タブ・ペインの一覧を問い合わせます。作業が終わったタブや確認待ちのタブを見つけたときは、そのタブの画面の中身も読みに行きます( herdr pane read )。ボタンが押されたときは、herdr にそのタブへの切り替えを頼みます。 ④ herdr が、各タブのエージェントの状態(作業中・確認待ち・完了・待機中)とセッション ID、頼まれたタブの画面の中身を返します。 ⑤ プラグインが、作業が終わったタブや確認待ちのタブの画面をローカル LLM に渡して、読み上げ用の文章を作るよう頼みます(Chat Completions)。 ⑥ ローカル LLM が、「何をしたか・どうなったか・次に何をすればいいか」をずんだもんの口調にまとめた文章を返します。 ⑦ プラグインが、その文章を VOICEVOX に渡して、ずんだもんの声にするよう頼みます。 ⑧ VOICEVOX が、ずんだもんの声の音声データを返します。 ⑨ プラグインが、各タブの状態をもとにボタンとインフォバーの画像を描き、Stream Deck に表示します。 ⑩ プラグインが、⑧ で受け取った音声をスピーカーで再生します。 ここからは、それぞれの部品を紹介します。 herdr:AI エージェントのためのターミナル管理ツール herdr は、AI コーディングエージェント向けのターミナルマルチプレクサです。tmux のように、1 つのターミナルの中で複数の作業場所を切り替えられます。 作業場所は「スペース」と「タブ」の 2 段で管理します。プロジェクトごとにスペースを作り、その中にタブを並べるイメージです。 herdr の良いところは、各タブで動いているエージェントの状態を把握してくれることです。 状態は   working (作業中)、 blocked (確認待ち)、 done (完了)、 idle (待機中)の 4 つで、CLI から JSON で取得できます。 herdr pane list { "id" : "cli:pane:list" , "result" : { "panes" : [ { "pane_id" : "w1:p1" , "tab_id" : "w1:t1" , "agent" : "claude" , "agent_status" : "idle" , "agent_session" : { "value" : "d4cd505c-..." } , "cwd" : "/Users/ntakei/data/tmp/20260921/書籍PDF" } ] } } 今回のプラグインは、この情報を 1.2 秒ごとに取りに行って、ボタンの表示に使っています。 Stream Deck Neo:ボタンに何でも表示できるランチャー Stream Deck は Elgato が出しているデバイスで、ボタンの一つひとつが小さな液晶になっています。ボタンを押すとアプリを起動したり、ショートカットを送ったりできる、いわゆるランチャーです。配信者の定番デバイスですが、開発者が使っても便利です。 今回使ったのは Stream Deck Neo です。ボタンが 8 個(4 個 × 2 段)あり、その下に「インフォバー」と呼ばれる横長の小さな画面があります。インフォバーの両側には、ページを切り替えるタッチボタンが付いています。 Stream Deck はプラグインを自作できます。プラグインは Node.js で動き、ボタンに表示する画像も自由に作れます。つまり、 ボタンに何を表示して、押したら何をするかを自分で決められる ということです。これを使って、herdr 専用の操作パネルを作りました。 自作プラグイン:herdr 専用の操作パネル ボタンの配置 上段の 4 つのボタンがスペース、下段の 4 つのボタンが、上段で選んだスペースのタブです。 上段のスペースを押すと、下段がそのスペースのタブに切り替わる 下段のタブを押すと、herdr でそのタブに切り替わる スペースが 5 つ以上あるときは、インフォバー横のボタンで Stream Deck のページを切り替える 1 つのスペースにタブが 5 つ以上あるときは、選択中のスペースボタンをもう一度押すと、下段が次の 4 タブに切り替わる ボタンに住むドット絵のキャラクター 各タブのボタンには、ドット絵のキャラクターが住んでいます。キャラクターは、そのタブのエージェントの状態を演じます。   ※ プラグインがボタンに描いている絵を、そのまま画像にしたものです。 上段のスペースのボタンには、そのスペースにいるキャラクターの顔が並びます。スペースの中に入らなくても、「このスペースで誰かが手を振っている(確認待ち)」と分かります。 ボタンの画像は、プラグインが SVG で描いて 150 ミリ秒ごとに差し替えています。絵が前回と同じなら送らないようにして、負荷を抑えています。 選択中のボタンは明るく 今選んでいるスペースとタブは、ボタンの背景が明るくなります。最初は白い枠で囲んでいましたが、小さなボタンでは分かりにくかったので、背景そのものを状態の色で明るく塗るようにしました。選んでいないボタンは暗いままなので、明るいボタンが 1 つだけ目に入ります。 押したらそのターミナルへ飛ぶ ボタンを押すと、herdr のタブを切り替えたうえで、herdr が動いているターミナルアプリ(今回は Ghostty)を前面に出します。macOS はアプリを前面に出すと、そのウィンドウがあるデスクトップ(Space)へ自動で切り替えてくれます。なので、ターミナルを別のデスクトップに置いていても、ボタン 1 つでそこへ飛べます。 インフォバーにコンテキスト残量と利用上限 ボタンの下のインフォバーには、次の 3 つを表示しています。 選んでいるタブの Claude Code のコンテキスト使用率と、その内訳 5 時間の利用上限の使用率と、リセットされる時刻 1 週間の利用上限の使用率と、リセットされる時刻 実機のインフォバーは、こんな感じです。 実機の画面は小さいので、それぞれ何を表しているのかを図に起こしました。 ※ 説明のために、プラグインと同じ描画コードで大きく描き直したものです。数値は写真とは別のときのものです。 どのタブの Claude Code かは、herdr がペインごとに持っているセッション ID で特定しています。使用率は、Claude Code の   /usage   と   /context   を、 claude -p   で裏から実行して取っています。 ずんだもんが読み上げてくれる仕組み ここからが一番気に入っているところです。 流れ ① プラグインが、1.2 秒ごとに herdr へ各タブの状態を問い合わせます。 ② herdr が各タブの状態を返します。プラグインは、前回から「作業中 → 完了」や「→ 確認待ち」に変わったタブを見つけます。 ③ プラグインが、そのタブの画面を読みます( herdr pane read   で直近 200 行)。 ④ herdr が画面の文字を返します。枠線や記号も混ざった、ターミナルの表示そのままです。 ⑤ プラグインが、画面の文字と「何をした → どうなった → 次にすること」の順で伝えるよう指示した文章を、ローカル LLM(Ollama の qwen3.5:9b)に渡します(Chat Completions)。 ⑥ ローカル LLM が、ずんだもんの口調の読み上げ文を返します。 ⑦ プラグインが、読み上げ文を VOICEVOX(Docker で起動)に渡します。話者はずんだもんです。 ⑧ VOICEVOX が音声データ(WAV)を返します。 ⑨ プラグインが、音声をスピーカーで再生します( afplay )。 どのスペースのタブでも読み上げます。文の頭に「業務自動化から。」のようにスペース名が付くので、どこの話か分かります。 ただ要約するのではなく「次に何をすればいいか」を伝える LLM に渡すプロンプトで一番工夫したのは、「要約して」とは頼んでいないことです。画面を見ていない人が、声だけを聞いて 次に何をすればいいか分かる ことを最優先にしてもらっています。 完了したときは、次の 3 つを 3 文で伝えてもらいます。 このセッションで何をしたか その結果どうなったか 次にユーザーがすべきこと(質問や選択肢があれば、その中身) 確認待ちのときは、次の 3 つです。 今何の作業をしている途中か 何の許可を求めているのか、何を質問しているのか(削除などの取り消しにくい操作は必ず言う) どう答えればいいか(選択肢は読み上げるが、どれを選ぶべきかは勧めない) 入力はターミナルの画面そのままなので、枠線や記号、入力欄なども混ざっています。それらは LLM に「無視して中身だけ読み取って」と伝えています。 実際の読み上げ 実際に読み上げられた文章です。 作業が終わったとき: 業務自動化から。未入力の工数を確認して割り振り案を出したのだ。6 日分を指定されたプロジェクトに正しく登録して集計も一致したのだ。月末の 2 日分を入力するか、期限までにどうするか確認してほしいのだ。 確認待ちのとき(コマンドの実行許可を求められた場面): テストを直すために依存パッケージを再インストールしてテストを流す作業の途中なのだ。node_modules を削除して依存関係を再構築し、テストを再実行するコマンドの許可を求めているのだ。許可する、今後も聞かずに許可する、やめて指示し直す、のどれかを選んでほしいのだ。 「削除する」ことをちゃんと伝えてくれて、選択肢も読み上げてくれます。これならターミナルを見に行く前に、答えを決められます。 このプラグインを作ったプロンプト このプラグインは、Claude Code に作ってもらいました。ソースコードは公開していませんが、代わりに「これを渡せば同じものが作れる」プロンプトを紹介します。 実際のやり取りは試行錯誤の連続だったので、完成したコードから逆算して、最初から渡しておけばよかった内容にまとめ直しました。 プロンプトは 3 つに分けています。1 回で全部を頼むより、段階ごとに実機で動きを確かめながら進めた方が、手戻りが少なく済みます。パスやアプリ名など、環境に依存するところは自分の環境に合わせて書き換えてください。 ステップ 1:ボタンでスペースとタブを操作する まずは土台です。herdr の状態をボタンに表示して、押したらそのタブに切り替わるところまでを作ります。 Stream Deck Neo(macOS)から herdr を操作するプラグインを TypeScript で作ってください。 SDK は @elgato/streamdeck 3.x、ビルドは rollup、プラグインの UUID は jp.example.herdr にします。 # herdr について - herdr は AI エージェント向けのターミナルマルチプレクサで、スペース(workspace)とタブで作業を管理する - 次の CLI で状態を取れる。--json オプションは無く、最初から JSON を返す。 結果は {"id": "...", "result": {...}} の形で包まれているので result を取り出すこと - herdr workspace list … workspace_id, label, focused, active_tab_id, agent_status - herdr tab list --workspace <id> … tab_id, label, agent_status(herdr の表示順で返るので並べ替えない) - herdr pane list … pane_id, tab_id, agent, agent_status, agent_session.value(セッション ID), cwd - herdr workspace focus <id> / herdr tab focus <tab_id> / herdr tab create --focus - agent_status は working / blocked / done / idle。unknown などそれ以外は「エージェントなし」として扱う - タブの状態は、そのタブのペインのうち最も深刻なもの(blocked > working > done > idle)にする # ボタンの配置 - 上段 4 つ:スペース。押すとそのスペースを選び、herdr 側でもフォーカスする - 下段 4 つ:上段で選んだスペースのタブ。押すとそのタブへ切り替える - どのボタンが何番目を表示するかは、設定画面の「スロット」(1〜16)で決める。スロットは通し番号にして、 Stream Deck のページ 2 に上段スロット 5〜8 を置けば、スペース 5〜8 を表示できるようにする - 1 つのスペースにタブが 5 つ以上あるときは、選択中のスペースボタンをもう一度押すと、下段が次の 4 タブに切り替わる (最後まで行ったら最初に戻る)。選択中のスペースボタンには 1/2 のように今の位置を出す - 押したときの緑のチェックマーク(showOk)は出さない。失敗したときだけ showAlert を出す # 見た目 - ボタンの画像は 144×144 の SVG をコードで生成して setImage で送る - 背景色で状態を表す:blocked 赤 / working 黄 / done ミント / idle 青 / エージェントなし 暗い紫 - 各タブのボタンには 14×12 マスのドット絵のキャラクターを置き、状態を演じさせる - working:机でタイピング(汗をかく) / blocked:手を振って「!」の吹き出し - done:バンザイして紙吹雪 / idle:居眠り(zzz) / エージェントなし:空席の椅子 - アニメーションは 150ms ごとに描き直す。タイマーは全ボタンで 1 本を共有し、前回と同じ画像なら送らない - 上段のスペースボタンには、そのスペースのタブのキャラクターの顔を小さく並べる - 名前は全角 4 文字がちょうど 1 行に入る大きさ(32px・太字 700)で、最大 2 行。 少しだけはみ出す名前や、空白のない英単語は、折り返さずに文字を縮めて 1 行に収める。 文字は真っ白にし、縁取りではなく右下にずらした薄い影で読みやすくする - 選択中のボタン(選んでいるスペースと、herdr でアクティブなタブ)は、背景を状態色の明るい淡い色にして、 文字を濃い紺にする。選んでいないボタンは暗くしない(次に押すボタンが読めなくなるため) # ターミナルを前面に出す - ボタンを押したら、herdr の切り替えの後に、herdr が動いているターミナルアプリを前面に出す - ターミナルアプリは、tty を持つ herdr プロセスから ps で親をたどり、実行パスに .app/Contents/MacOS/ を含む 最初のプロセスから探す(アプリ名は決め打ちしない)。見つけたら open -a <アプリのパス> で前面に出す - macOS はアプリを前面に出すと、そのウィンドウがあるデスクトップへ自動で切り替えてくれる # 注意点 - Stream Deck から起動したプロセスはシェルの PATH を持たない。herdr は /opt/homebrew/bin などを探して 絶対パスで呼ぶ。設定画面でパスを指定することもできるようにする - herdr へのポーリングは 1.2 秒に 1 回、全ボタンで 1 本だけにする - manifest.json には CodePath を必ず書く。完成したら streamdeck validate でチェックする - 設定画面(Property Inspector)は外部ライブラリを使わず、素の HTML と WebSocket で作る。 select の値は文字列で保存されることがあるので、プラグイン側で数値に直す ステップ 2:インフォバーにコンテキスト残量と利用上限を出す 次に、ボタンの下のインフォバーを作ります。 Stream Deck Neo のインフォバー(232×50)に、Claude Code の情報を表示するアクションを追加してください。 インフォバー全体を 1 つの pixmap にして、プラグインで描いた SVG を base64 の data URI で setFeedback に渡します。 # 表示する内容 - 上段:選んでいるタブの Claude Code のコンテキスト使用率(%)、内訳の積み上げバー、トークン数(例:636k/1m) - 下段:5 時間の利用上限と 1 週間の利用上限の使用率(%)と、リセットされる時刻 - 使用率の色は、70% までミント、90% まで黄、それ以上は赤 - 文字は小さい画面でも読めるよう大きめにする(見出し 14px、% は 17〜18px) # データの取り方 - 利用上限:claude -p "/usage" --output-format json --no-session-persistence - コンテキスト:claude -p --resume <セッションID> "/context" --output-format json --no-session-persistence - どちらも 60 秒ごと。/context は重いので、会話の記録ファイルの更新時刻が変わったときだけ実行する - プローブ自身がセッションとして残らないよう、環境変数 CLAUDE_CODE_SESSION_NAME で名札を付けて見分ける # 選んでいるタブのセッションの見つけ方 - herdr の pane list の agent_session.value にセッション ID が入っているので、まずそれを使う - herdr が「エージェントなし」と言っているタブは、他の方法で推定せず「このタブは Claude なし」と表示する (同じディレクトリで別のタブの Claude が動いていても、それを拾わないようにするため) - 会話の記録ファイルは ~/.claude/projects/<作業ディレクトリ>/<セッションID>.jsonl にある。 ディレクトリ名は、作業ディレクトリのパスの英数字以外をすべて - に置き換えたもの(日本語も 1 文字ずつ - になる)。 見つからなければ ~/.claude/projects の下をセッション ID で探す ステップ 3:ずんだもんに読み上げさせる 最後に、読み上げの仕組みを足します。 Claude Code などのエージェントの作業が終わったときと、確認待ちになったときに、 その内容をローカル LLM で読み上げ用の文章にして、VOICEVOX のずんだもんに読み上げさせる機能を このプラグインに追加してください。すべてのスペースのタブを対象にします。 # きっかけ - herdr のタブの状態が working → done、または working → idle に変わったら「作業が終わった」とする (見ているタブの作業が終わると、herdr は done を経由せず idle になる) - done → idle は「見た」だけなので読まない - 状態が blocked に変わったら「確認待ち」とする - プラグインの起動直後(前回の状態が無いとき)は読まない # 流れ 1. そのタブのペインの画面を herdr pane read <pane_id> --source recent-unwrapped --lines 200 --format text で読む。 直後は失敗することがあるので、間を空けて最大 3 回試す 2. ローカル LLM に Chat Completions(POST {URL}/chat/completions)で画面を渡し、読み上げ用の文章にしてもらう。 thinking で出力を使い切らないよう reasoning_effort: "none" を付ける 3. VOICEVOX の /audio_query と /synthesis で音声にして、afplay で再生する 4. 文の頭に「<スペース名>から。」を付ける - 読み上げは 1 件ずつ順番に。3 件より多く溜まったら古いものから捨てる。同じ画面は二度読まない # LLM へのプロンプトで伝えること - あなたはずんだもんで、画面を見ていないユーザーに音声で作業状況を伝える - 入力はターミナル画面そのままなので、枠線・記号(❯ ⏺ ⎿ ▎ ─ │ など)・入力欄・ステータス行・切れた行は無視する。 ❯ で始まる行がユーザーの依頼、⏺ で始まる行がエージェントの返答で、一番下が最新 - 聞き手が「次に自分が何をすればいいか」を分かることを最優先にする - 作業が終わったとき:何をしたか → どうなったか → 次にユーザーがすべきこと(質問や選択肢があればその中身)を 3 文で - 確認待ちのとき:何の作業中か → 何の許可・質問か → どう答えればいいか を 3 文で。 削除・上書きなど取り消しにくい操作は必ず言う。選択肢は読み上げるが、どれを選ぶべきかは勧めない - すべての文をずんだもんの口調(〜のだ、〜なのだ)で終える。前置きや敬語は使わない。 コマンドやファイルパスはそのまま読まずに言い換える。120 文字以内 - 出力の例を 2〜3 個入れる # 起動していないとき - VOICEVOX やローカル LLM は起動し忘れることがある。読み上げ前に VOICEVOX の /version と LLM の /models に 1.5 秒だけ問い合わせ、どちらかが応答しなければ、ユーザーに何も通知せずその回を飛ばして処理を続ける - 読み上げた・飛ばした理由はプラグインのログにだけ残す # 設定 - インフォバーの設定画面に、プラグイン全体で共通の設定(グローバル設定)として次を置く。空欄は既定値 - 読み上げのオン・オフ(既定:オン) - LLM の URL(既定:http://127.0.0.1:11434/v1)とモデル(既定:qwen3.5:9b) - VOICEVOX の URL(既定:http://127.0.0.1:50021)と話者 ID(既定:3 = ずんだもん) まとめ herdr、Stream Deck、ローカル LLM、VOICEVOX を組み合わせて、AI エージェントとの並行作業をかなり快適にできました。 ボタンを見れば、全タブの状態が分かる ボタンを押せば、そのターミナルへ飛べる 終わったら、ずんだもんが次にやることまで教えてくれる 仕事はずんだもんにまかせておいて、5時から男としゃれこみましょう。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 5人がこの投稿は役に立ったと言っています。 The post オレのClaude Code作業環境、控えめにいって最高すぎる〜Stream Deckでherdrを操作、完了はずんだもんが読み上げ〜 first appeared on SIOS Tech Lab .
アプリを多言語対応(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 .
はじめに こんにちは!サイオステクノロジーのなーがです。前回、 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 .