ホーム / お役立ち記事 / Notion Workers の同期で日本語が1文字だけ化ける理由|ASCII で書き出して防ぐ

Knowledge

Notion Workers の​同期で​日本語が​1文字だけ化ける​理由|​ASCII で​書き出して​防ぐ

Notion Workers の同期で、日本語の値の1文字だけが置換文字(U+FFFD)になる文字化けが起きることがあります。弊社の実測では、同期が1回に返した値の32,768バイト(32KiB)ごとの境目にかかった字が化け、例外も警告も出ませんでした。原因の切り分け、見分け方、ASCII で書き出す直し方、全件の走査での確かめ方を、弊社の実測と SDK のコードでまとめます。

著者: 村山 悠太(ゴムマリ) 最終更新: 2026年11月

Notion Workers の同期で、Notion に入った日本語の値の1文字だけが、置換文字「U+FFFD」に化けることがあります。弊社の同期では、成功の扱いのまま、例外も警告も出ませんでした。

弊社が位置を調べた所では、化けたのは、同期が1回に返した値の先頭から32,768バイト(32KiB)ごとの境目にかかった字でした。その字が U+FFFD の2〜3個に置き換わっていました。

直し方は、返す値の ASCII でない字を \uXXXX の形で書き出すことです。弊社の同期では、この形に替えた後、約55万バイトを1回で返しても化けは0でした。

この記事は、Notion Workers の同期を作る人・任されている業務の担当者・情報システムの担当者に向けて書きます。

弊社の同期での実測(2026年9月〜10月)、手元の再現(2026年11月2日)、Notion の公開の文書と SDK のコードで整理します。

原因: 返した​値の​32,768バイトごとの​境目で、​字が​割れる

Notion Workers は、Notion が提供する、コードで Notion を拡張する仕組みです。

公式の説明では、その1つとして、Salesforce などの API のデータを Notion のデータベースへ同期する用途が挙がっています。

同期は、データを返す関数 execute を Notion が呼ぶ形で動きます。SDK(@notionhq/workers)は、execute が返した値を JSON の文字列にして、標準出力に1回で書き出します。

書き出しの関数(writeOutput)は、弊社が読んだ SDK の版 0.4.0・0.9.2・0.9.5 で同じでした。0.9.5 は、2026年11月2日の時点で npm に出ている最新の版です。

Notion がこの出力をどう文字に直しているかは、公開の文書に書かれていません。2026年11月2日に読んだ Workers と CLI の文書の7頁には、文字コードやバイトの上限の記載がありませんでした。

弊社の見立ては、Notion が出力を32,768バイトずつ区切り、区切りごとに別々に文字に直している、というものです。化けた字が32,768の倍数の境目をまたいでいたことと、この読み方を真似た手元の再現で同じ化け方が出たことによります。

日本語の字は、UTF-8 で1字が3バイトです。境目が字の途中にかかると、前の区切りと後ろの区切りに字のバイトが分かれ、どちらも字として読めずに U+FFFD になります。

境目が字の切れ目や英数字に当たれば、何も起きません。化けが表のあちこちに少しだけ出るのは、このためです。

弊社の​同期で​起きた​こと

弊社は、お客様の Salesforce のデータを、毎日 Notion のデータベースへ同期する仕組みを、Notion Workers で作っています。

2026年9月、同期を1本足したとき、新しい表の値の1文字が U+FFFD の並びに化けているのを見つけました。位置は、その回に返した値の先頭から32,768バイトの5倍の所でした。

化けた字は「ド」で、UTF-8 の3バイト(e3 83 89)が、e3 83 と 89 に分かれていました。

ほかの同期も調べると、前から動いていた3つの表にも、数セルずつ同じ化けが入っていました。例外も警告も出ていませんでした。

2026年10月、Salesforce を読まずに日本語の60行を1回で返す、使い捨ての同期で試しました。そのまま返した約27万5千バイトは、32,768バイトの2倍・5倍・6倍・8倍の位置で、60行中4行が化けました。

原因の​切り分け: 読み取りでも​書き込みでもなく、​返した​値の​取り込み

化けを見つけたとき、まず Salesforce から読む側を疑いました。受け取った本文を1回で文字に直し、U+FFFD が残っていたら書かずに止める形にしました。

それでも、Notion の表には化けた字が入りました。読み取りは無傷で、同期が値を返した後に化けていることになります。

Notion の CLI で同期の実行の記録(ntn workers runs logs)を見ると、記録に残った返り値の時点で、既に化けていました。

Notion がデータベースへ書く段ではなく、返り値を受け取る段で起きている、と切り分けました。

手元では、2026年11月2日に、SDK の本物の writeOutput が書いたバイト列を、32,768バイトずつ別々に文字に直す読み方で読みました。

Node.js 26.9.0 で、SDK 0.4.0・0.9.2・0.9.5 のどれでも同じ結果でした。

確かめたこと 結果
日本語60行・約26万バイトを、区切って読む 境目8か所のうち、字の途中にかかった6か所だけで化けた。化けた1か所ごとに U+FFFD 2〜3個
同じバイト列を、続けて1回で文字に直す 化けは0
ASCII で書き出してから、区切って読む 化けは0。JSON を読むと元の値と一致
全体が32,768バイトより小さい返り値を、区切って読む 化けは0

化けた6行は、どれも例外なく JSON として読めました。弊社の同期で見た「例外も警告も出ず、数か所だけ化ける」形と同じです。

これは見立ての読み方を真似た再現で、Notion の中を確かめたものではありません。

見分け方​: 化けが​1文字だけで、​返り値が​32,768バイトを​超えているか

見え方 疑う所
1文字が U+FFFD の2〜3個に置き換わり、前後の字は正しい この記事の件
化けたセルを書いた回の返り値が、32,768バイトを超えている この記事の件
1回の返り値が32,768バイトより小さい回にも化けがある この件ではない。元のデータか、読み取りの側
元のシステムの画面でも、同じ字が化けている 元のデータ

返り値の大きさは、execute の中で、返す値を JSON.stringify した文字列の UTF-8 のバイト数で見積もれます。

Node.js なら Buffer.byteLength(JSON.stringify(value)) です。

実際の出力は、SDK が足す包みの分だけ少し大きくなります。

直し方​: 返り値の​ ASCII でない​字を​ \uXXXX で​書き出す

JSON では、字を \u3042 のような ASCII の6字で書いても、読む側で元の字に戻ります。出力を全部 ASCII にすれば、どこで区切られても、1つの字のバイトが割れることはありません。

標準出力の書き込みを包み、返り値の印(<__notion_output__>)を含む書き込みだけを置き換えます。弊社が本番に入れた形を、部品にして載せます。

// ascii_output.ts  index.ts の先頭(Worker を作る前)で installAsciiOutput() を1回呼ぶ
const TAGS = ["<__notion_output__>", "<__notion_error__>"];

/** ASCII でない字を \uXXXX にする(4バイトの字は UTF-16 の2つの単位になる) */
export function toAscii(s: string): string {
  return s.replace(/[^\x00-\x7f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
}

let installed = false;

/** SDK が返り値と失敗を書き出す行だけを ASCII にする。ほかの出力(console.log など)は変えない */
export function installAsciiOutput(): void {
  if (installed) return;
  installed = true;
  const write = process.stdout.write.bind(process.stdout) as (...args: any[]) => boolean;
  (process.stdout as any).write = (chunk: any, ...rest: any[]) => {
    if (typeof chunk === "string" && TAGS.some((t) => chunk.includes(t))) chunk = toAscii(chunk);
    return write(chunk, ...rest);
  };
}

失敗の書き出し(<__notion_error__>)も、同じく置き換えます。

JSON の括弧や引用符は ASCII なので、置き換わるのは文字列の中身だけです。「𠮷」や絵文字のような4バイトの字は、UTF-16 の2つの単位(\ud842\udfb7)で書かれ、JSON の決まりどおりに読めます。

弊社の実測(2026年10月)では、この形で約55万バイトを1回で返しても、60行すべてが正しく入り、化けは0でした。本番に入れた後の1周(SDK 0.9.2・約40回の実行・1回の最大約1MB)でも、12の表の U+FFFD は0でした。

ASCII にすると、返り値は大きくなります。弊社の同期の行では1.3〜1.5倍、日本語だけの文では手元の再現で約2倍でした。

1回に返せる量の上限は、公式の文書と SDK の同梱の手引きに見当たりません。弊社は、1回を約1MBまでに分けて返しています。

値の側で先に \u3042 の形に直すのは誤りです。SDK の JSON.stringify が \ をもう一度逃がすので、Notion には「\u3042」の文字のまま届きます(手元の再現で確かめました)。

別の直し方: 1回の返り値を32,768バイトより小さく分ける

返り値を小さく分けて返しても防げます。hasMore: true と nextState を返すと、Notion が execute を続けて呼ぶ仕組みが、公式の手引きにあります。

弊社も2026年9月〜10月は、1回を24,000バイト以下に分けて返していました。バイトで測る形に直した後、12の表・約6万7千行を書き直し、化けは0でした。

分けるときは、字の数ではなく UTF-8 のバイト数で測ります。公式の手引きは1回を約100件から始めるよう勧めていますが、件数で決めると、日本語の長い文を含む行では32,768バイトを超えることがあります。

弊社は、ASCII の書き出しに替えました。分けた分だけ実行の回数が増え、費用と、1時間あたりの実行の上限に響いたためです。

ASCII の書き出しは SDK の書き出しの作りに頼るので、SDK の版を上げたら、dist/capabilities/output.js の writeOutput を読み、同期の後に U+FFFD を数え直します。

落とし穴: 実際に​踏んだ​もの

  • 件数と列の照合では見つからなかった化けは1文字ずつで、行の数や列の照合では違いが出ず、画面の目視でも気づきにくい数でした。
  • 字の数で測って分けていた2026年9月、24,000で分けたつもりの1本が、字の数で測っていました。日本語の多い行では1回が最大約3万4千バイトで、32,768バイトを超えていました(このときは化けた字はありませんでした)。
  • 分けたら、実行の回数が増えた2026年9月から、分けた分だけ実行の回数が増え、1日約800回の主な原因になりました。多くの同期を一度に動かした日は、1時間あたりの実行の上限に当たり、全部の同期が約40分止まりました。
  • 同期の表の値は、API で直せなかった同期が書いた表の行は、公開の API で足すことも値を変えることもできず、400で拒まれました。化けた値は、直した同期で全行を書き直して消しました。
  • 同期が作った表は、統合のトークンから見えなかった同期が作る表は、デプロイした人の非公開の場所にでき、統合のトークンで読むと404でした。読み戻すには、表を統合と共有した場所へ移しました。

確かめ方​: 同期の​後に、​全部の​表の​ U+FFFD を​数える

化けたかどうかは、Notion の表を API で読み戻し、全部のセルの文字に U+FFFD があるかを数えて判定します。

次のコードは、Node.js 18 以降で、表(データソース)を全部読み、U+FFFD を含むセルを並べます。標準の機能だけで動きます。

// scan_fffd.mjs  使い方: NOTION_TOKEN=<トークン> node scan_fffd.mjs <データソースの ID> ...
import { pathToFileURL } from "node:url";

const API = "https://api.notion.com/v1";
const FFFD = String.fromCodePoint(0xfffd);
const HEADERS = {
  Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
  "Notion-Version": "2026-03-11",
  "Content-Type": "application/json",
};

/** プロパティの値に入っている文字列を、全部取り出す */
function strings(v, out = []) {
  if (typeof v === "string") out.push(v);
  else if (Array.isArray(v)) v.forEach((x) => strings(x, out));
  else if (v && typeof v === "object") Object.values(v).forEach((x) => strings(x, out));
  return out;
}

/** 表を全部読み、U+FFFD を含むセルを返す */
export async function scan(dataSourceId, fetchFn = fetch) {
  const hits = [];
  let rows = 0;
  let cursor;
  do {
    const res = await fetchFn(`${API}/data_sources/${dataSourceId}/query`, {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify({ page_size: 100, ...(cursor ? { start_cursor: cursor } : {}) }),
    });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    const body = await res.json();
    if (body.request_status?.type === "incomplete") throw new Error("1万行の上限で切れた。created_time で区切って読む");
    for (const page of body.results) {
      rows++;
      for (const [name, prop] of Object.entries(page.properties)) {
        for (const s of new Set(strings(prop))) {
          const i = s.indexOf(FFFD);
          if (i >= 0) hits.push({ page: page.id, property: name, at: i, around: s.slice(Math.max(0, i - 10), i + 10) });
        }
      }
    }
    cursor = body.has_more ? body.next_cursor : undefined;
  } while (cursor);
  return { rows, hits };
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  for (const id of process.argv.slice(2)) {
    const { rows, hits } = await scan(id);
    console.log(`${id}: ${rows} 行・U+FFFD を含む値 ${hits.length}`);
    for (const h of hits) console.log(`  ${h.page} ${h.property} ${h.at}字目 …${h.around}…`);
  }
}

1回の問い合わせで読めるのは1万行までと、Notion の文書に書かれています。超える表は、文書のとおり作成日時(created_time)で区切って読みます。上のコードは、切れたときに止まります。

弊社の同期では、直した後に12の表・約6万7千行を数え、U+FFFD は0でした。数千行の表なら、数分で終わります。

元のデータに、もともと U+FFFD が入っていることもあります。見つかったら、元のシステムの同じ値と比べてから直します。

上の2つのコードは、2026年11月2日に Node.js 26.9.0 で確かめ、本物の Notion には送っていません。

ASCII の部品は SDK の本物の書き出しで、走査は API の応答を真似た作り物で、化けたセルを全部拾うことを見ました。

自力で​どこまで​やれるか

同期のコードを自社で持っていれば、直しは上の部品を1つ足し、index.ts の先頭で呼ぶだけです。U+FFFD の走査も、表の数が少なければ手で回せます。

自力の壁は次の2つです。

  • 既に化けた値を消すには同期で全行を書き直す必要があり、置き換えの同期か差分の同期かを踏まえて、入れ方を決める
  • SDK の版を上げるたびに、書き出しの作りを読み、全部の表を走査し直す仕組みが要る

外注する​場合の​判断基準

外注の分かれ目は、同期した表の値が元のシステムと全件で合っていることを、自社で確かめ続けられるかです。外注するなら、見積もりを比べる前に次の3点を確かめてください。

  • 同期の受け入れで、件数だけでなく、値の中身(U+FFFD を含む)を全件で照合するか
  • 1回の返り値を、字の数でなくバイトで測って分けるか、ASCII で書き出すか
  • SDK の版を上げたときに、書き出しの作りと全件の走査を確かめ直すか

ゴムマリでは、ほかのシステムのデータを Notion などへつなぐ同期の設計・構築・受け入れの照合(件数・値の一致)・保守を、システム連携・API 開発として承ります。

料金は、連携1本(接続先2つ・片方向・定期の更新)で初期50万円から、保守は月3万円から、期間は2週間からです。

同期の書き込み先になる Notion のデータベースの設計と構築から頼む場合は、業務データベースの開発・構築(Notion 対応)で承ります。料金は初期30万円から、保守は月3万円から、期間は2週間からです。

進め方は相談からご連絡ください。

Notion で頼めることの全体は、Notion の構築と開発にまとめています。

次の​一歩

まず、同期している表を1つ選び、上のコードで U+FFFD を数えてください。0でなければ、その行を書いた回の返り値が32,768バイトを超えていたかを見ます。

化けがあれば、ASCII の書き出しを入れて同期で全行を書き直し、もう一度数えて0になったかで判定します。

Salesforce で消した値が Notion に残る件は、Salesforce から Notion への同期で消した値が残る理由にまとめています。そちらは、空の値の送り方の層です。

Notion API で、字の数え方の違いから長い文が拒まれる件は、Notion API で長い文が 400 で拒まれる理由にまとめています。そちらは、公開の API へ直接書く層です。

関連記事: Salesforce から Notion への同期で消した値が残る理由/Notion API で長い文が 400 で拒まれる理由/Notion Workers の料金が増える理由

この記事を書いた人

代表 村山悠太のポートレート

村山 悠太(ゴムマリ代表)

ワークスアプリケーションズで大手企業向けERPの営業と導入を担当し、外資系SaaS企業と国内大手放送局で販路と新規事業の立ち上げ、AI学習用データを扱うAIスタートアップ2社でマーケティングと営業を担った。2024年10月から生成AIの実装に入り、いまは大手企業の現場で、AIエージェントを要件定義から運用まで手がけている。

代表の経歴を見る

Ask

この​業務を​頼むなら

Notion をお使いの会社

Notion の構築と開発

データベースの設計と移行、ほかのシステムとの同期、Notion AI で書類を作り照合する仕組み、Notion Workers の開発、保守まで、まとめてお引き受けします。

初期 30万円〜(2週間〜)

Notion の構築と開発を見る

Related

関連

FAQ

よく​ある​質問

化けるのは日本語だけですか?

弊社が Notion で化けを見たのは、日本語の字だけです。手元の再現では、UTF-8 で4バイトの絵文字も境目で割れて U+FFFD になりましたが、Notion では確かめていません。

返り値を小さく分ける直し方と、ASCII で書き出す直し方は、どちらがよいですか?

弊社は ASCII の書き出しにしました。分ける直し方でも化けは0になりましたが、分けた分だけ実行の回数が増えたためです。ASCII の書き出しは SDK の書き出しの作りに頼るので、SDK の版を上げたら確かめ直します。

既に化けて入った値は、どう直しますか?

弊社の実測では、同期が書いた表の行は、公開の API で値を変えられず、400 で拒まれました。直した同期で全行を書き直し、その後に12の表の U+FFFD を数えて、0になったことを確かめました。

エージェントのツールやウェブフックの返り値も化けますか?

確かめていません。SDK のコードでは、ツールとウェブフックも同じ書き出しの関数を使っています。この記事の ASCII の書き出しの部品は、返り値の印を含む書き込みを全部置き換えるので、ツールとウェブフックの返り値にも効く作りですが、それも確かめていません。

例外も警告も出ないのに、どうやって気づけますか?

同期の後に Notion の表を API で読み戻し、全部のセルの文字に U+FFFD があるかを数えます。弊社では、件数と列の照合では見つからず、化けは画面の目視でも気づきにくい数でした。

ご相談は​こちらから

相談は無料です。

相談する