はじめに
X(旧Twitter)の投稿の上限「280」は、280字という意味ではありません。文字ごとに重みがあり、その合計が280以内なら投稿できます。日本語は1文字が2なので、日本語だけなら140字です。
投稿文をプログラムで作るときは、この重みで数える必要があります。とくに、文面を作る側と投稿する側が別のプログラムのとき、両者の数え方がずれていると「作れるのに投稿できない」文面が生まれます。
この記事では、次の3つを紹介します。
- X 公式の設定どおりに重みを数える実装(TypeScript と Python)
- 上限を「280」ではなく「280 − あとから必ず付くもの」にする考え方
- 2つの言語の実装が同じ数を返すかを突き合わせるテストと、公式との違い
この記事の要点
- X の上限280は文字数ではなく重み。日本語と絵文字は2、URL は長さに関係なく23
- 本文の上限は「280 − あとから必ず付くもの」。
#不動産を必ず付けるなら272 - 同じ規則を2つの言語で持つなら、同じ文字列で突き合わせるテストを置く
環境
| 項目 | 内容 |
|---|---|
| TypeScript | Node.js 24.14.1(.ts を型除去でそのまま読み込み) |
| Python | 3.13.7 |
| OS | Windows 11 |
| 外部ライブラリ | なし |
1. 公式の数え方
X の開発者向けドキュメント「Counting Characters」の説明を要約すると、次のとおりです。
| 文字の種類 | 重み |
|---|---|
| ラテン文字・句読点・よく使う記号 | 1 |
| 絵文字 | 2(肌の色つき・ZWJ で連結したものも1つで2) |
| 日本語・中国語・韓国語(CJK) | 2 |
| その他の Unicode | 2(既定値) |
| URL | 長さに関係なく 23(t.co で短縮されるため) |
公式は、数える前に文字を NFC(Unicode 正規化形式C)にそろえます。また、ZWJ(ゼロ幅接合子)でつないだ絵文字も、全体で2と数えます。
どの文字が重み1かは、公式ライブラリ twitter-text の設定ファイルに書かれています。
{
"version": 3,
"maxWeightedTweetLength": 280,
"scale": 100,
"defaultWeight": 200,
"emojiParsingEnabled": true,
"transformedURLLength": 23,
"ranges": [
{ "start": 0, "end": 4351, "weight": 100 },
{ "start": 8192, "end": 8205, "weight": 100 },
{ "start": 8208, "end": 8223, "weight": 100 },
{ "start": 8242, "end": 8247, "weight": 100 }
]
}
scale が100なので、weight: 100 が重み1、defaultWeight: 200 が重み2です。重み1になるのは、U+0000〜U+10FF・U+2000〜U+200D・U+2010〜U+201F・U+2032〜U+2037 の4つの範囲だけで、それ以外はすべて重み2です。
2. TypeScript で数える
設定ファイルの範囲をそのままコードにします。
/** http(s):// で始まるURL。ASCII の印字可能文字が続くところまでを1つとみなす(日本語の手前で切る)。 */
const URL_RE = /https?:\/\/[\x21-\x7e]+/g
/** コードポイントが「重み1」に該当するか(twitter-text の ranges と同じ)。 */
function isWeightOne(cp: number): boolean {
return (
cp <= 0x10ff ||
(cp >= 0x2000 && cp <= 0x200d) ||
(cp >= 0x2010 && cp <= 0x201f) ||
(cp >= 0x2032 && cp <= 0x2037)
)
}
/** X方式の重み。URLを23文字相当の半角に置き換えてから、コードポイントごとに1か2を足す。 */
export function xWeight(s: string): number {
const replaced = String(s || '').replace(URL_RE, 'x'.repeat(23))
let w = 0
for (const ch of replaced) { // for...of はサロゲートペアを1文字として回る
const cp = ch.codePointAt(0) || 0
w += isWeightOne(cp) ? 1 : 2
}
return w
}
for...of で回すと、絵文字のようなサロゲートペアも1つのコードポイントとして扱えます。s.length や s[i] で数えると、絵文字1つが2単位に割れてしまいます。
URL の終わりは、ASCII の印字可能文字が続くところまでとしました。[^\s]+ のように「空白まで」とすると、https://example.com/lessonを見て のように URL の直後に日本語が続いたとき、日本語まで URL に含めてしまいます。その分を23の中に数えるので、実際より少なく数えることになります。
3. 上限は「280」ではなく「280 − あとから付くもの」
投稿する側で、決まったハッシュタグを必ず末尾に足す、という作りはよくあります。その場合、文面を作る側が280ちょうどまで使うと、あとから足すタグが入りません。上限は、あとから必ず付く分を先に引いた値にします。
/** Xの投稿上限(重み)。 */
export const X_LIMIT = 280
/** 必ず付けるハッシュタグの付与に必要な重み(タグ本体+直前の改行1)。 */
export function xForcedTagReserve(channel: string): number {
const tags = X_FORCED_TAGS[channel] || []
return tags.reduce((n, t) => n + xWeight(t) + 1, 0)
}
/** 本文に使ってよい重みの上限。 */
export function xBodyBudget(channel: string): number {
return X_LIMIT - xForcedTagReserve(channel)
}
たとえば必ず付くタグが #不動産 なら、# が1、漢字3文字が各2で重み7、改行が1で、合わせて8です。本文に使えるのは 280 − 8 = 272 になります。
この上限の値は、文面を作るすべての入口で同じ関数から取ります。入口ごとに「280」と書くと、直したはずのずれが別の入口で戻ってきます。
4. Python でも同じ規則で数える
画像や動画を作る処理が Python で書かれていて、そこで投稿文も作る、ということがあります。その場合は Python にも同じ規則を書きます。
import re
X_WEIGHT_LIMIT = 280
X_FORCED_TAG = "#不動産" # 投稿する側が必ず付けるタグ(TypeScript 側と揃える)
URL_RE = re.compile(r'https?://[\x21-\x7e]+')
def x_weight(s):
"""Xの重み。全角=2・半角=1・URLは23固定。TypeScript の xWeight と同じ規則。"""
s = URL_RE.sub('x' * 23, str(s or ''))
w = 0
for ch in s: # Python の str はコードポイント単位で回る
cp = ord(ch)
one = (cp <= 0x10ff or 0x2000 <= cp <= 0x200d
or 0x2010 <= cp <= 0x201f or 0x2032 <= cp <= 0x2037)
w += 1 if one else 2
return w
def x_budget():
"""本文に使える重み。必ず付くタグのぶんを先に引く。"""
return X_WEIGHT_LIMIT - (x_weight(X_FORCED_TAG) + 1)
同じ規則を2つの言語で書くと、どちらか片方だけを直す、ということが起きます。そこで、2つが同じ数を返すかを突き合わせるテストを用意します。
5. 2つの実装を突き合わせる
TypeScript 側は本物のファイルをそのまま読み込み(Node.js 24 は .ts の型を取り除いて読み込めます)、Python 側は子プロセスで呼び出して、同じ文字列の重みを比べます。
import { execFileSync } from "node:child_process";
const { xWeight } = await import("file:///C:/path/to/xweight.ts");
const CASES = ["Hello, world!", "宅建試験まであと1か月", "「覚え方」・ひとつ。", "続きは…",
"https://example.com/a/very/long/path?with=query&and=more",
"詳しくは https://example.com/lesson を見てください",
"😀", "👍🏽", "🇯🇵", "👨👩👧", "#宅建 #宅建試験"];
const py = execFileSync("python", ["-c", [
"import sys, json",
"sys.stdin.reconfigure(encoding='utf-8')",
"sys.stdout.reconfigure(encoding='utf-8')",
"sys.path.insert(0, r'C:\\path\\to\\python_module')",
"import xweight",
"print(json.dumps([xweight.x_weight(s) for s in json.loads(sys.stdin.read())]))",
].join("\n")], {
// 文字列は \uXXXX にエスケープした ASCII の JSON で渡す(文字コードの設定に左右されない)
input: JSON.stringify(CASES).replace(/[\u007f-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")),
encoding: "utf8",
env: { ...process.env, PYTHONIOENCODING: "utf-8" },
});
const pyW = JSON.parse(py.trim().split("\n").pop());
CASES.forEach((s, i) => console.log(s, xWeight(s), pyW[i], xWeight(s) === pyW[i] ? "" : "★不一致"));
実行結果です(見やすいように、ケースの名前とコードポイントの数を添えて整えています)。
半角英数 文字コード 13個 TS= 13 Python= 13
日本語 文字コード 11個 TS= 21 Python= 21
全角記号「」・。 文字コード 10個 TS= 20 Python= 20
三点リーダ … 文字コード 4個 TS= 8 Python= 8
URL(長い) 文字コード 56個 TS= 23 Python= 23
URL+日本語 文字コード 39個 TS= 47 Python= 47
絵文字1つ 😀 文字コード 1個 TS= 2 Python= 2
肌の色つき 👍🏽 文字コード 2個 TS= 4 Python= 4
国旗 🇯🇵 文字コード 2個 TS= 4 Python= 4
家族(ZWJ連結)👨👩👧 文字コード 5個 TS= 8 Python= 8
ハッシュタグ 文字コード 9個 TS= 15 Python= 15
TypeScript版とPython版は全件一致
測る側の文字コードに注意
Windows の Python 3.13 は、パイプから読む標準入力を、既定では UTF-8 ではなくシステムの文字コード(日本語環境では cp932)で読みます。上のコードから reconfigure と PYTHONIOENCODING を外し、UTF-8 の文字列をそのまま渡すと、宅建試験まであと1か月(11文字・重み21)が18文字に化け、重み35と数えられました。実装は正しいのに「不一致」に見えます。
そのため、読む側で sys.stdin.reconfigure(encoding='utf-8') を呼び、渡す側も ASCII だけの JSON(\uXXXX のエスケープ)にしています。言語をまたいで文字列を渡すテストでは、実装より先に渡し方を疑うのが近道です。
6. 公式との違い:連結した絵文字
突き合わせの結果から、この実装と公式の違いも見えます。公式は「絵文字は、どれほど複雑でもすべて2」と説明しています。
| 文字列 | コードポイント | この実装 | 公式 |
|---|---|---|---|
| 😀 | 1 | 2 | 2 |
| 👍🏽(肌の色つき) | 2 | 4 | 2 |
| 🇯🇵(国旗) | 2 | 4 | 2 |
| 👨👩👧(ZWJ で連結) | 5 | 8 | 2 |
この実装は、絵文字をコードポイントごとに数えるので、いくつかの文字を組み合わせて1つに見せる絵文字を、公式より多く数えます。多く数える方向のずれなので、この点では上限を超えません。そのかわり、連結した絵文字を多く使う文面では、必要以上に短く削ることがあります。
また、公式は文字を NFC(正規化形式C)にそろえてから数えます。この実装は正規化をしていません。日本語の文面では差が出ることはまずありませんが、分解済みの文字(e + 結合用のアクセント記号など)を含む場合は数が変わります。TypeScript なら s.normalize('NFC')、Python なら unicodedata.normalize('NFC', s) を最初に通せば揃います。
少なく数えてしまう場合:http の付かないドメイン
逆に、公式より少なく数える場合もあります。twitter-text は既定で、example.com のように http:// の付かないドメインも URL として扱い、23と数えます(extractUrlsWithIndices の既定値 extractUrlsWithoutProtocol: true)。この実装は http(s):// で始まるものしか URL とみなさないので、example.com(11文字)を11と数えます。公式との差は8です。
| 文字列 | この実装 | 公式 |
|---|---|---|
https://example.com |
23 | 23 |
example.com |
11 | 23 |
この実装を使うなら、文面にドメインを書くときは必ず https:// を付ける、という決まりを一緒に守ります。上限に余裕を持たせておく(第7章)のも、この差を吸収するためです。
正確に公式と同じ数が必要なら、公式の twitter-text の parseTweet を使うのが確実です。外部の依存を増やしたくない、文面に絵文字をほとんど使わない、URL は必ず https:// 付きで書く、という条件がそろうなら、この実装でも運用できます。
7. 収まらないときは「値打ちの低い行」から落とす
上限を超えたときに文字を後ろから切ると、締めの一文やタグが消えます。そこで、残したい部分(head)は固定し、締めの候補(tails)を上から順に試す作りにしています。
NL = "\n"
def _fit(head, tails, limit):
"""収まる形を上から順に試す。head は必ず残す行、tails は締めの候補。"""
for tail in tails:
body = NL.join(head + tail)
if x_weight(body) <= limit:
return body
return body # どれも収まらなければ、最後に試した形をそのまま返す
_fit は、どれも収まらなかったときに文字を切り詰めません。最後に試した形を返し、収まっているかの判定は、出す前の検査1か所に任せます。
def gate(name, body, limit=None):
"""出す前に通す検査。通らないものは出さない。"""
bad = []
if limit:
w = x_weight(body)
if w > limit:
bad.append('重み %d > %d' % (w, limit))
# (ほかに、禁止表現や締めの一文の有無などを確かめる)
return bad
for name, body, limit in outs:
bad = gate(name, body, limit)
if not bad:
write_out(name, body) # 検査に通ったものだけを書き出す(=投稿に回る)
どの形でも収まらない文面は、検査で「重み ○ > ○」として止まり、書き出されません。途中で切れた文面が投稿されるより、出さないほうが安全です。運用する上限は、本文の上限(272)よりさらに内側(たとえば260)にしておくと安心です。あとから付く要素が増えたり、前章のように公式より少なく数える文字列が混ざったりしても、すぐにはあふれません。
よくある質問
X に日本語は何文字まで投稿できる?
日本語だけなら140字です。日本語は1文字が重み2で、上限が重み280だからです。半角の英数字は重み1なので、混ぜると140字より多く入ります。
URL は何文字として数えられる?
長さに関係なく23です。X は URL を t.co で短縮するためです。http:// の付かない example.com のようなドメインも、URL として23と数えられます。
まとめ
- X の上限280は重み。日本語・絵文字は2、URL は23、重み1は公式設定の4つの範囲だけ
- 数え方は公式の設定ファイル(twitter-text の
config/v3.json)どおりに書く。for...ofなどでコードポイント単位に回す - 上限は「280 − あとから必ず付くもの」。すべての入口で同じ関数から取る
- 同じ規則を2つの言語で持つなら、同じ文字列で突き合わせるテストを置く。言語をまたぐときは文字コードの渡し方に注意
- コードポイント単位の実装は、連結した絵文字を公式より多く数える。一方で
httpの付かないドメインは公式より少なく数えるので、上限に余裕を持たせる。正確さが要るなら公式ライブラリを使う
参考
- X Developers: Counting Characters
- twitter/twitter-text(GitHub)
- twitter-text の設定ファイル config/v3.json
- twitter-text の URL 抽出 extractUrlsWithIndices.js
- MDN: for...of(文字列はコードポイント単位で反復される)
- Python: io.TextIOWrapper.reconfigure
- Python: PYTHONIOENCODING
筆者(合同会社My Agent Works)は、宅建士試験の学習アプリ「宅建BOOST」(https://takken-boost.jp/)を開発しています。開発で得た知見を今後も投稿します。
X: https://x.com/takkenboost