0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

X(旧Twitter)の文字数を「重み」で数える ― 公式設定どおりの実装と、2つの言語の実装を突き合わせるテスト

0
Posted at

はじめに

X(旧Twitter)の投稿の上限「280」は、280字という意味ではありません。文字ごとに重みがあり、その合計が280以内なら投稿できます。日本語は1文字が2なので、日本語だけなら140字です。

投稿文をプログラムで作るときは、この重みで数える必要があります。とくに、文面を作る側と投稿する側が別のプログラムのとき、両者の数え方がずれていると「作れるのに投稿できない」文面が生まれます。

この記事では、次の3つを紹介します。

  1. X 公式の設定どおりに重みを数える実装(TypeScript と Python)
  2. 上限を「280」ではなく「280 − あとから必ず付くもの」にする考え方
  3. 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 の付かないドメインは公式より少なく数えるので、上限に余裕を持たせる。正確さが要るなら公式ライブラリを使う

参考


筆者(合同会社My Agent Works)は、宅建士試験の学習アプリ「宅建BOOST」(https://takken-boost.jp/)を開発しています。開発で得た知見を今後も投稿します。
X: https://x.com/takkenboost

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?