21
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSVやMarkdownをそのままNotionに表として貼り付けられるCLI作った

Last updated at Posted at 2025-12-10

こんにちは!any 株式会社でプロダクトチームに所属しているエンジニアのなおとぅ(@Rasukarusan)です!
この記事は、any Product Team Advent Calendar 2025 シリーズ3の 11 日目の記事になります。

はじめに

ターミナルの出力やCSVファイルの中身を、NotionやGoogleスプレッドシートに表として貼り付けたい。そんなことありませんか?

例えば、こういうデータをコピーして、

$ cat hoge.tsv
名前    年齢    職業
田中    30      エンジニア

そのまま Notionに貼り付けると、表にならずテキストのまま貼り付けられてしまう。

image.png

そうじゃなくてこうなってほしい↓。

image.png

これを解決するCLIツール table-to-clipboard を作ったのですが、その過程でmacOSのクリップボードやコマンドについて、いくつか発見があったので共有します。

TL;DR

  • macOSのクリップボードは複数の形式を同時に保持できる
  • NotionやGoogleスプレッドシートに表として貼り付けるにはRTF形式でコピーする必要がある
  • pbcopyはプレーンテキスト専用。RTFはtextutilでHTML→RTF変換し、SwiftからCocoaのNSPasteboardでセット

macOSクリップボードの仕組み

複数形式の同時保持

macOSのクリップボードは、実は同じデータを複数の形式で同時に保持しています。

試しに、スプレッドシートで表をコピーして貼り付けてみると、

$ pbpaste
名前    年齢    職業
田中    30      エンジニア

プレーンテキストが出力されます。
でもNotionに貼り付けるとちゃんと表として認識されます。

image.png

これはスプレッドシートからコピーした際、プレーンテキストリッチテキストの両方を保持するようになっているからなんですよね。内部的には以下のような形です。

形式 用途
public.utf8-plain-text プレーンテキスト(ターミナルへの貼り付け等)
public.rtf リッチテキスト(Word、スプレッドシート等)

これはmacOSのNSPasteboardの仕組みで、各データ形式はUniform Type Identifier (UTI)で識別されています。

pbcopyの限界

pbcopyは便利ですが、プレーンテキストしかコピーできません

echo "名前\t年齢\n田中\t30" | pbcopy  # プレーンテキストとしてコピーされる

これをNotionに貼り付けても、表として認識されずテキストのまま貼り付けられてしまいます。

RTF形式でクリップボードにコピーする

Notionに表として貼り付けるには、RTF形式でコピーする必要があります。

Step 1: HTMLテーブルを作る

まず、データをHTMLのテーブルに変換します。

<table>
  <tr>
    <th>名前</th>
    <th>年齢</th>
  </tr>
  <tr>
    <td>田中</td>
    <td>30</td>
  </tr>
</table>

Step 2: textutilでRTFに変換

macOS標準のtextutilでHTMLをRTFに変換できます。

textutil -convert rtf -output output.rtf input.html

Step 3: Cocoaでクリップボードにセット

SwiftからCocoaのNSPasteboardを直接呼び出してクリップボードにセットします。

import Cocoa

let rtfPath = "/tmp/clipboard-temp.rtf"
let textPath = "/tmp/clipboard-temp.txt"

guard let rtfData = FileManager.default.contents(atPath: rtfPath),
      let textData = FileManager.default.contents(atPath: textPath) else {
    exit(1)
}

let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setData(rtfData, forType: .rtf)
pasteboard.setData(textData, forType: .string)

RTFとプレーンテキストの両方を同時にセットして、貼り付け先に応じて適切な形式が使われるようにします。

実装例

一連の流れをTypeScriptで書くとこんな感じ。

function copyHtmlToClipboard(html: string, plainText: string): void {
  const tempHtml = "/tmp/clipboard-temp.html";
  const tempRtf = "/tmp/clipboard-temp.rtf";
  const tempText = "/tmp/clipboard-temp.txt";
  const tempSwift = "/tmp/clipboard-temp.swift";

  // HTMLを保存
  const fullHtml = `<!DOCTYPE html><html><body>${html}</body></html>`;
  fs.writeFileSync(tempHtml, fullHtml);
  fs.writeFileSync(tempText, plainText);

  // RTFに変換
  execSync(`textutil -convert rtf -output "${tempRtf}" "${tempHtml}"`);

  // Swiftスクリプトでクリップボードにセット
  const swiftCode = `
import Cocoa

let rtfPath = "${tempRtf}"
let textPath = "${tempText}"

guard let rtfData = FileManager.default.contents(atPath: rtfPath),
      let textData = FileManager.default.contents(atPath: textPath) else {
    exit(1)
}

let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setData(rtfData, forType: .rtf)
pasteboard.setData(textData, forType: .string)
`;
  fs.writeFileSync(tempSwift, swiftCode);
  execSync(`swift "${tempSwift}"`);
}

TypeScriptというより、シェルコマンドやSwiftも混ざり合った、夢のコラボレーションスクリプトになっていますね!
こういうカオスなスクリプトは大好きです😇

開発中の細かい対応

AppleScriptの文字数制限

先ほどのCocoaのNSPasteboardでクリップボードにコピーする箇所は、最初はosascript(AppleScript)を使ってクリップボードにセットしていました。

osascript -e 'set the clipboard to {«class RTF »:«data RTF XXXX»}'

XXXXの部分にはRTFファイルの内容を16進数エンコードしたものが入ります。

しかし、hex文字列が長すぎるとエラーが出るという問題がありました。少しでも大きなテーブルになると簡単にこの制限に引っかかってしまったので、SwiftのCocoaを利用する形にしました。

リテラル文字列の対応

# これは動く
echo -e "名前\t年齢" | table-to-clipboard

# これは動かない(\tがタブにならない)
echo "名前\t年齢" | table-to-clipboard

echo -eを使うと\tはタブに変換されますが、-eなしだと\tただの2文字(バックスラッシュ + t)です。
また、echoの挙動はbashzshでも異なるので、この辺の差異を吸収するため、リテラル文字列を実際のエスケープシーケンスに変換する処理を入れました。

function unescapeLiterals(text: string): string {
  return text.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
}

これで、どちらのパターンでも動くようになりました。

スペース区切りの対応

もう一つ細かいですが、ターミナルの出力をコピーした場合にも対応しました

# ターミナルでTSVを出力
cat data.tsv
# 名前    年齢    職業
# 田中    30      エンジニア

この出力をマウスで選択してコピーすると、タブがスペースに変換されています

ターミナルは表示時にタブを複数のスペースに展開して表示するため、見た目は同じでも中身が違うんですよね。

解決策としてはシンプルに「2つ以上の連続スペース」を区切り文字として認識するようにしました。

function parseSpaceSeparated(data: string): string[][] {
  return data
    .trim()
    .split("\n")
    .map((line) => {
      // 2つ以上の連続スペースで分割
      return line.split(/  +/).map((cell) => cell.trim());
    });
}

これなら Hello World のような単一スペースはセル内に残り、列の区切りだけを認識できます。

SlackはpbcopyでOK

ちなみにSlackの場合は、RTFにしなくてもタブ区切りのプレーンテキストなら表として認識されます。

# Slackの場合はこれでOK
echo -e "名前\t年齢\n田中\t30" | pbcopy

完成したツール

こうして完成したのが table-to-clipboard です。

# インストール
npm install -g @rasukarusan/table-to-clipboard

対応形式は以下の4種類で、コピーしたものをとりあえずパイプで渡せばOKです。

  • TSV(タブ区切り)
  • CSV(カンマ区切り)
  • スペース区切り(2つ以上の連続スペース)
  • Markdownテーブル
# 使い方
pbpaste | table-to-clipboard
cat data.csv | table-to-clipboard
echo "A,B,C" | table-to-clipboard

個人的にはスペース区切りやマークダウンの表を貼り付けるときに、気持ちよさを感じますね😄

# スペース区切りの表
名前    年齢    職業
田中    30      エンジニア

# markdownで出力された表
| 名前 | 年齢 | 職業     |
|------|----:|----------|
| 田中 | 30   | エンジニア |

特に最近はClaude Codeを使っている際に、markdownで出力されることが多いのでよく活躍しています。

終わり

ちょっとしたツールでも、実際に作ってみると知らないことが多くあり、学びがたくさんありました。
AI時代、モノ自体は簡単に作れるので、気になった挙動など細かい部分を深掘りする時間が多く取れて楽しいですね!
これからも色々作って知識を増やしていきたいです💪

それではまた!


any株式会社ではナレッジ経営クラウドQastのエンジニアを絶賛募集中です。
是非採用ページをご覧ください!

エンジニア組織/文化について詳しく知りたい方はこちら

21
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?