散らばったnote戦略の資料をローカル限定ページに集約し、PNGしかないロゴをSVG化した
昨日 note の新アカウントを作った。その作業をどのリポジトリでやったのか、翌日には思い出せなくなっていた。まず「昨日の作業はどこでやったか」を調べさせるところから始まった。
記事にしたもの、計画書、調査で集めたデータが別の場所に置かれている。1本ずつは残っているのに、まとめて見渡す入口がない。
最初はディレクトリを切り分けようと考えた。ただ、分けたところで探す手間は減らない。減るのは「どこに置くか」の迷いだけで、「どこにあるか」の迷いは残る。それなら、非公開の記事を一覧しているあの仕組みと同じ形で、1ページに寄せてしまえばいい。
非公開の仕掛けが2系統あった
このリポジトリには、本番に出さないための仕組みが2つ入っている。
- frontmatter に
unpublished: true—/blog/unpublishedに一覧され、本番のブログ導線からは外れる。ファイル自体はcontent/にあるので Nuxt Content には載る content/_local-unpublished/配下に置く —content.config.tsの exclude で Content から丸ごと外れる。本番ビルドにはファイルの存在すら残らない
note の戦略記事は後者に置いてある。つまり本番では中身を読めない。索引ページを作るなら、この性質を壊さない作りにしないと意味がない。
そこで、グループ分けの規則から本文とタイトルを追い出した。判定に使うのは frontmatter の tags だけにして、戦略の中身が本番バンドルに乗る経路を最初から作らない。
分類は純粋関数に切り出した
拾う条件は単純にした。tags に note-strategy が1つ入っていれば戦略ドキュメントとして扱う。記事側で意識するのはこのタグ1個だけで、置き場所やファイル名の規約は増やさない。
そのうえで、束ね方を5つに決めた。何を決めたか(意思決定と進捗)、何をいくらでどの形で売るか(商品と課金の設計)、買い手はどこで何に困っているか(市場と読者のリサーチ)、先行している人の売り方の分解(競合調査)、そこから外れたもの(その他)。読む順番としてもこの並びが自然だった。
グループ定義を配列で持ち、配列の順序をそのまま判定の優先順位と画面の表示順に兼用させた。ひとつの記事が複数グループの tags に該当することはある。競合の料金を調べた記事は「競合調査」と「市場調査」の両方を持つ。先に書いたグループが勝つ、という一行の規則で決着させた。
export function classifyNoteStrategyDoc(tags) {
const found = NOTE_STRATEGY_GROUPS.find(
group => group.tags.length > 0 && group.tags.some(tag => tags.includes(tag)),
)
return found?.id ?? 'other'
}
並べ替えは更新日を先に見て、無ければ公開日。日付が読めないものは空文字になって後ろへ落ちる。件数0のグループは結果から消す。この程度の関数でも、境界の挙動を決めておかないと画面で「なぜこの順番なのか」が説明できなくなる。
テストを書かせて、その時点の22件すべてが通った。
テストが通ったあと、カードが縦に伸びていた
トップページの「コンテンツ」欄には、ローカルでしか出ないカードがすでに何枚か並んでいる。新しい形を考えるより、その実装をそのまま踏襲させたほうが早い。既存のカードを読ませてから、同じ作りで1枚追加させた。ここから /note-strategy に入ると、記事9本と memo の計画書4点、関連2本が分野別に並ぶ。
dev で開いて確認したら、カードの説明文が長くて縦に伸びていた。文字を削って収めた。テストが22件通ったところで満足していたら、そのまま気づかずに置いていた。
全文検索が総当たりで5秒を超えていた
同じ日に、別の未解決も片付けた。「公開記事から非公開記事へのリンクが残っていないか」を調べるテストが、タイムアウトで落ちていた。
原因は総当たりだった。非公開パスを1つ取るたびに全公開記事の本文を includes で舐める作りになっていて、計算量が非公開数 × 公開数 × 本文長で伸びる。記事1,800本・非公開200本の規模で、5秒の制限を超えていた。
向きを逆にした。非公開パスを Set に入れておき、公開記事を1回だけ走査してリンク先を照合する。
const unpublishedPaths = new Set(articles.filter(a => a.unpublished && a.path).map(a => a.path))
for (const art of articles) {
if (art.unpublished) continue
for (const match of art.content.matchAll(MARKDOWN_LINK_TARGET)) {
if (unpublishedPaths.has(stripTrailingSlash(match[1]))) { /* 記録 */ }
}
}
速くなっただけで挙動が変わっていたら意味がないので、テスト2件で旧実装と突き合わせた。どちらも検出0件で一致した。同じ記事から同じ非公開パスへ2回リンクしていても1件にまとめる、という粒度まで合わせてある。
ヘッダー画像を作ろうとして、ロゴがPNGしかないと分かった
ブランド名は「ハカドリ」。note に出すヘッダー画像を用意しようとして、背景のグラフィックを Codex に生成させ、そこへロゴとタグラインを合成する段取りを組んだ。画像生成に日本語テキストを描かせると崩れるので、文字は自分で置く。
ところが合成の段で、手元にあった自作 SVG の鳥とロゴ原本の鳥が別物だった。ロゴをそのまま使う形に組み直そうとして、探した。ベクターは0件だった。生成 AI が作った PNG 6案しかなく、ブランド資産としてのベクターはゼロ。
このまま使えば、拡大するたびに輪郭がにじむ。使う場所ごとに微妙に違うロゴが増えていく。SVG 化を先に済ませることにした。
トレースを捨てて、Codexに座標を書かせて、また捨てた
順に試した。
1. Adobe の vectorize に投げる — MCP が接続不可。ここで止まった。
2. 原本 PNG の形状を数値的にトレースする — この日、memo ディレクトリのファイルを dev サーバー経由で返すローカル API を作っていた。おかげで画像を同一オリジンで読めるので、canvas に載せてピクセルを数える下地はそろっていた。
ただ、この方向は自分で捨てた。原本1枚に縛られたまま精度を追う作業に、時間を溶かす予感がした。ロゴの形はもう決まっている。決まっているものを機械に測らせ直す前に、そもそも別の起こし方がないか見ておきたかった。
3. Codex に SVG コードを直接テキストで書かせる — 画像を経由せず、最初からベクターで案を出させる。生成を待つ間に、採用済みの構図を踏まえた案を1つ書かせて、合計4案が並んだ。
並べて見て、すぐ決まった。3案のうち2つは鳥に見えず、魚だった。使える方向は「現行構図をベクター化した1案」だけだった。
原因は SVG という形式ではない。LLM は座標を想像で打っている。画像生成 AI は絵を見ながら作るから見栄えが出るが、手打ちの SVG は図形を頭の中で組むので造形が拙くなる。見栄えのいい PNG はすでに手元にあるのだから、形を忠実に変換する作業に閉じるのが早い。
そう整理したうえで、ベクター化は自分の手で済ませた。
色が13種類に分裂していた
起こした SVG を開くと、色が13種類に分かれていた。ベクター化の副産物で、同じ色が微妙に違う値で何度も定義されている。これでは「ブランドカラー」と言えない。
中を数えたら3系統だった。マゼンタ1・白7・紺5。同じ系統をひとつの値に寄せて正規化した。
形の忠実度は差分表示で確かめた。一致した箇所が黒く出る比較画像がほぼ真っ黒で、ずれているのは輪郭1px程度のアンチエイリアス差だけ。24px まで縮めても原本と見分けがつかない。手を入れたのは色の正規化だけで済んだ。
ここからパーツの座標を測って切り出した。鳥・文字・背景、それにベクター化で混ざったゴミを分離して、アイコン用・横組み・ヘッダー用と用途ごとに展開し、5点にした。
1回失敗している。切り出した SVG を開いたら色が黒く落ちていた。原因は元 SVG が <defs><style> のクラスで色を定義していたことで、切り出し先にクラス定義を持っていくのを忘れていた。塗りが属性で書かれている前提で切ったのが間違いだった。
資産がそろったので、ヘッダー画像を作り直した。1920×1006 で、note にそのまま上げられる。
羽ばたきは全位相を目で見た
ベクターになったので、羽ばたくロゴも作れる。サブエージェントに派遣して4種類書かせた。
数値の検証は済んでいるが目視は未実施、という状態で返ってきたので、ローカル配信 API 経由でブラウザに出して全位相を確認した。破綻はなかった。1.2秒・振幅±15度の案は、翼の上下がはっきり分かって、体との接続にも隙間が出ない。
数値が通っているという報告だけで受け取っていたら、翼が胴体から離れる位相を見逃していた可能性がある。動くものは動かして見るしかない。
noteに動画は置けなかった
匿名で発信している人の弱点は、動画を持てないことだと思っている。顔はぼかせても声は隠せない。逆に言えば、実名で出して資格を持っている立場なら、動画は強みが直接効く領域になる。
そこで note の動画対応を調べさせた。公式ヘルプが403で返ってきたので、r.jina.ai 経由に切り替えて一次ソースを取った。
- 「動画」の投稿形式は2025年6月9日で終了している。note に動画を置いて再生させることはできない
- ファイルのアップロードは50MBが上限
- 有料エリアに置いても容量制限は同じ50MB。有料と無料の違いはアクセス制御の話で、容量とは別軸。購入者だけがダウンロードできるようになるだけで、サイズは増えない
売り物としてスキルを配るなら ZIP で足りる。実体は SKILL.md というマークダウン1枚と補助ファイルで、数KBから数百KB。50MB の壁にはぶつからない。
書かないと決めた記事
限定公開の動画をダウンロードする手順は、AI エージェントに頼めばライブラリを入れて数行で通る。それ自体をコンテンツにできる、と一度は考えた。
相談したら、実名・資格を持つ立場のブランドで出す記事としては避けたほうがいいと返ってきた。特定の動画プラットフォームの規約に触れる手順を配って得られる読者は、こちらが欲しい読者ではない。納得したので書かないと決めた。目的は購入者に動画ファイルを渡すことで、そこは別の手段で足りる。
進捗記事を実態の数字で書き換えた
進捗の更新が途中で止まっていた。ディスクを確認したら、記事0本・ヘッダー画像なし。
見栄えのいい進捗に寄せる誘惑はあったが、その数字のまま書き換えた。索引ページ・SVG 資産・動画の方針・企画書、この日に手を動かした分だけを足して、/note-strategy-progress を dev で開いて描画も確認した。
0本を0本と書いておかないと、次に開いたときに自分が判断を誤る。「準備は進んでいる」と書いてある進捗記事は、進んでいない事実を隠す道具になる。
学び
- ベクター資産を用意する前に画像を使い始めると、あとで全部作り直す。 ロゴを PNG で6案作って選んだ時点では、それで足りているように見えていた
- 造形を LLM の座標打ちに任せるのは無理がある。 見栄えは絵を見ながら作る道具に任せ、ベクター化は「形を忠実に変換する」作業に閉じる
- テストが通ったことと、画面が正しいことは別。 22件パスの直後に、カードは縦に伸びていた
- 速くした実装は、旧実装と突き合わせて挙動一致を確認する。 検出0件どうしの一致でも、確認した記録があるかどうかで次の判断が変わる
計画書のレビューは自分の手元に残っている。判断3件を返すところから続ける。