🌾

X(旧:Twitter)の動的シェアテキストを文字数上限ぴったりに収める

2023/12/12に公開

eyecatch

はじめに

X(旧:Twitter)のシェアリンクは、https://twitter.com/intent/tweet?text=******部分に投稿本文を設定できます。しかし、コンテンツのタイトルなど可変長の文字列を含める場合、文字数上限をオーバーすることがあります。

ユーザーに手間を取らせないよう、溢れる分は「…」などの省略文字で適切に置き換えたいところですが、Xの文字数カウントは独自仕様なため、lengthプロパティでは正確にカウントできません。

この記事では、オーバー分を省略文字に置き換えつつ、文字数上限ぴったりに収めて気持ちよくなる実装例を紹介します。

twitter-textで文字数をカウントする

twitter-textは、Twitter(現:X)公式から提供されている投稿テキストの解析用ライブラリです。

その中のparseTweet()は、引数に渡した文字列を解析して以下の結果を返します。

  • weightedLength : Twitter(現:X)における文字数
  • permillage : 文字数上限(280文字)に対する千分率
  • valid : ポスト(旧:ツイート)可能な文字列か
  • displayRangeStart : 文字列全体の開始位置
  • displayRangeEnd : 文字列全体の終了位置
  • validDisplayRangeStart : ポストとして有効な部分文字列の開始位置
  • validDisplayRangeEnd : ポストとして有効な部分文字列の終了位置

weightedLengthの値を利用することで、文字数上限に対して何文字オーバーしているか(=何文字削れば上限ぴったりに収められるか)を計測できます。

早速使ってみましょう。

yarn add twitter-text

例として、書籍のタイトル・著者・冒頭・URLを含むシェアテキストを生成します。

import twitterText from 'twitter-text';

type Book = {
  id: number;
  title: string;
  author: string;
  opening: string;
};

const book: Book = {
  id: 1234567890,
  title: '吾輩は猫である',
  author: '夏目漱石',
  opening: '吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。',
};

function createShareText(book: Book): string {
  const base = '今日はこの本を読んだよ!\n'
    + '\n'
    + `${book.title}${book.author}\n`;
  const opening = book.opening;
  const url = '\n' + `https://example.com?id=${book.id}`;

  const parsedTweet = twitterText.parseTweet(base + opening + url);
  /*
    {
      weightedLength: 326,
      permillage: 1164,
      valid: false,
      displayRangeStart: 0,
      displayRangeEnd: 187,
      validRangeStart: 0,
      validRangeEnd: 142,
    }
  */
}

weightedLengthが上限280文字を超えており、validからこのままではツイート(現:ポスト)不可と判定されていますね。正確な文字数カウントができるようになったところで、次は制限内に収める方法を考えます。

上限ぴったりに収めて省略する

今回の例では、オーバー分はopeningの文字数を削って「…」で省略表示します。

Twitterの投稿画面では末尾に自動で半角スペースが入り、この半角スペース分で文字数オーバーするケースがあるため、加味して文字数チェックしておきます。

function createShareText(book: Book): string {
  const base = '今日はこの本を読んだよ!\n'
    + '\n'
    + `${book.title}${book.author}\n`;
  const opening = book.opening;
  const url = '\n' + `https://example.com?id=${book.id}`;

  // 文字数チェックし、省略不要ならそのまま返す
  // 投稿画面で末尾に半角スペースが入るため、加味して文字数チェック
  const parsedTweet = twitterText.parseTweet(base + opening + url + ' ');
  if (parsedTweet.valid) {
    return encodeURIComponent(base + opening + url);
  }
}

valid:trueになるまでひたすら1文字ずつ削ってparseTweet()し続けてもいいのですが、もう少しライトなカウント関数としてgetTweetLength()が用意されているので、こちらを使って考えてみましょう。

テキスト全体の文字数weightedLengthから上限である280文字を引くと、オーバー分の文字数になります。そしてopeningの文字数からこのオーバー分を引けば、制限内に収まるopeningの最大文字数が得られます。

function createShareText(book: Book): string {
  const base = '今日はこの本を読んだよ!\n'
    + '\n'
    + `${book.title}${book.author}\n`;
  const opening = book.opening;
  const url = '\n' + `https://example.com?id=${book.id}`;

  // 文字数チェックし、省略不要ならそのまま返す
  // 投稿画面で末尾に半角スペースが入るため、加味して文字数チェック
  const parsedTweet = twitterText.parseTweet(base + opening + url + ' ');
  if (parsedTweet.valid) {
    return encodeURIComponent(base + opening + url);
  }

  // 省略文字
  const ellipsis = '…';
  // 制限内に収まるopeningの最大文字数
  const maxLength =
    twitterText.getTweetLength(opening) -
    (parsedTweet.weightedLength -
      twitterText.configs.defaults.maxWeightedTweetLength);
}

あとはopeningのツイート文字数がmaxLengthになる位置を二分探索で探します。

ただし日本語文字は1文字で2文字分カウントであるため、必ずしも280文字ジャストになるとは限りません。ぴったりの位置が見つからない場合は近似値に調整しておきます。

function createShareText(book: Book): string {
  const base = '今日はこの本を読んだよ!\n'
    + '\n'
    + `${book.title}${book.author}\n`;
  const opening = book.opening;
  const url = '\n' + `https://example.com?id=${book.id}`;

  // 文字数チェックし、省略不要ならそのまま返す
  // 投稿画面で末尾に半角スペースが入るため、加味して文字数チェック
  const parsedTweet = twitterText.parseTweet(base + opening + url + ' ');
  if (parsedTweet.valid) {
    return encodeURIComponent(base + opening + url);
  }

  // 省略文字
  const ellipsis = '…';
  // 制限内に収まるopeningの最大文字数
  const maxLength =
    twitterText.getTweetLength(opening) -
    (parsedTweet.weightedLength -
      twitterText.configs.defaults.maxWeightedTweetLength);
  
  let start = 0;
  let end = opening.length;
  let truncated;

  // 二分探索で省略位置を探す
  while (start <= end) {
    const mid = Math.floor((start + end) / 2);
    truncated = opening.slice(0, mid) + ellipsis;
    const currentLength = twitterText.getTweetLength(truncated);

    if (currentLength > maxLength) {
      end = mid - 1;
    } else if (currentLength < maxLength) {
      start = mid + 1;
    } else {
      break;
    }
  }

  // ぴったりの位置がない場合は近似値に調整
  if (start > end) {
    truncated = opening.slice(0, end) + ellipsis;
  }

  return encodeURIComponent(base + truncated + url);
}

tweet

きもちいい!

おわりに

Xのことは多分一生Twitterと呼ぶしポストのことは多分一生ツイートと呼びます

参考

chot Inc. tech blog

Discussion