X(Twitter)への投稿字数を調整する

概要

LOTD(今日のリスト)を X(Twitter) に投稿する際、140字に切り詰める処理を実装した。

背景

これまで外部サービスを使っていたLOTDのXへの自動投稿を、お手製のプログラムに切り替えました。下記のような内容を、メール配信と同じタイミングで投稿します。

[今日のリスト] 【タイトル】: 【抜粋】
【パーマリンク】
【ハッシュタグ化したタグ】

たとえば「4つの元型(ユング)」であれば次のような感じです。

[今日のリスト] 4つの元型(ユング): 『元型は、カール・グスタフ・ユングが提唱した概念。世界中の神話や夢や芸術などに普遍的に見られるパターンのこと。元型は、個人的な無意識ではなく、人類に共通する「集合的無意識」から発生するとされる。』
https://listfreak.com/list/20747
#ユング #人格 #元型 #無意識 #脳科学

しかし、これをTwitter APIに送ると403エラーが返ってきます。不親切なことに「字数が長いよ」ではなく「投稿は許されない」という抽象的なエラーメッセージだったので、字数オーバーであること自体に気づくのに時間がかかりました。

投稿が長い場合には、抜粋で調整しようと思います。たとえば下記のように。

[今日のリスト] 4つの元型(ユング): 『元型は、カール・グスタフ・ユングが提唱した概念。世界中の神話や夢や芸術などに普遍的に見られるパターンのこと。元型は、個人的な無意識ではなく、人類に共通する「集合的無意識」から発生す…
https://listfreak.com/list/20747
#ユング #人格 #元型 #無意識 #脳科学

内容

「字数」と「重み付き字数」

Xでは日本語140字(characters)、英語280字まで投稿できると言われます。これは日本語1字に2字ぶんの「重み」(weight)が付けられているからです。

なので「あいuえお」の字数(length)は5字ですが、Xの数え方では9字です。ややこしいので、Xの数え方に従った字数を「重み付き字数」と称します。「あいuえお」の字数は5字、「重み付き字数」は9字です。

全角=2字、半角=1字というわけでもない

PHPには半角文字を 1、全角文字を 2 として字幅を数える mb_strwidth() や、同じ数え方で指定した字幅で文字列を丸める mb_strimwidth() があります。しかしPHPでは字数 1 と判定される「…」が重み付き字数では2になるなど、字の数え方が X とすこし異なります。

twitter-text がルールブック

X の公式サイトによると、ポストの字数制限は twitter-text というオープンソースライブラリで提供しているとのこと。しかし残念ながらPHPのライブラリはありません。そこで構成ファイルだけを借りて軽量なクラスを作りました。

class LF_Twitter_Text {

  // twitter-text configuration
  private static $tt = null;

  // 初期化
  public static function init() {}

  // 構成内容の取得
  public static function get() {}

  // 文字列の重み付き字数を数える
  public static function count_str( $string ) {}

  // 文字列の後ろを指定された重み付き字数ぶん切り落とす
  public static function truncate( $string, $length ) {}

  // 渡された1文字の重み付き字数を返す
  private static function count_char( $char ) {}
}

初期化メソッドは次のようにしました。構成情報をファイルに置くと置き場所を考えるのが面倒だしめったに変わる情報ではないのでハードコード。versionが上がったらJSON部分を入れ替えます。

public static function init() {

  // 重複して呼ばれ得るので、すでに値が入っていればそれを返す
  if ( self::$tt ) {
    return self::$tt;
  }

  self::$tt = json_decode( '
{
  "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
    }
  ]
}'
  );
}

字数カウントの核となる、重み付き字数のカウント部分はこのような感じにしました。大部分が日本語、次が英数字などいわゆる半角文字なので、その順番にヒットして処理を終了できるようにちょっと工夫をしました。

private static function count_char( $char ) {

  // UTF-8のコードポイントを取得
  if ( false === $code = mb_ord( $char, 'UTF-8' ) ) {
    return false;
  }

  // 大部分を占める日本語をまず判定
  // ranges 配列の最後のメンバーのendとまず比較する
  if ( end( self::$tt->ranges )->end < $code ) {
    return self::$tt->defaultWeight / self::$tt->scale;
  }

  // 次に多いと思われるメジャーな半角文字から判定する
  foreach ( self::$tt->ranges as $range ) {
    if ( $range->start <= $code && $code <= $range->end ) {
      return $range->weight / self::$tt->scale;
    }
  }

  // 残りは全て2字幅
  return self::$tt->defaultWeight / self::$tt->scale;
}

これらのメソッドを使って下記のような感じで投稿する文字列を作成しました。

// Twitter-Text configuration の内容を取得する
$tt = LF_Twitter_Text::get();

// パート1: [今日のリスト] タイトル: 抜粋
$xpost_part1       = '[今日のリスト] '.$post->post_title.': '.$post->post_excerpt;
$width_part1       = LF_Twitter_Text::count_str( $xpost_part1 );

// パート2: (改行)permalink(改行)ハッシュタグ
$xpost_part2       = PHP_EOL.$post_url.PHP_EOL.$hash_tags_str;
// パート2の文字幅を計算する。URLは固定長文字列をダミーとしてあてがう
$xpost_part2_dummy = PHP_EOL.str_repeat( '-', $tt->transformedURLLength ).PHP_EOL.$hash_tags_str;
$width_part2       = LF_Twitter_Text::count_str( $xpost_part2_dummy );

// 必要ならパート1の文字数を削る
if ( 0 < $over_width = $width_part1 + $width_part2 - $tt->maxWeightedTweetLength ) {
  $xpost_part1 = LF_Twitter_Text::truncate( $xpost_part1, $over_width );
}

// X投稿
$xpost = $xpost_part1.$xpost_part2;