Closed37

LINE Business から「友だち」の LINE ID 一覧を作成する

ピン留めされたアイテム
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

GitHub リポジトリ

MIT ライセンスでご利用いただけます。

https://github.com/tatsuyasusukida/line-friend-export

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

訳あって LINE Business から「友だち」の名前と LINE ID の一覧表を作成する必要が生じた。

このスクラップでは一覧表を作成するまでの過程を記録していく。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

簡単な方法

連絡先ページにアクセスしてすべての「友だち」を表示させてから開発者ツールのコンソールで下記の式を実行する。

連絡先ページで実行
[...document.querySelectorAll('#content table tbody tr')].map((el) => [el.querySelector('td:nth-child(1)').innerText, el.querySelector('td:nth-child(4) a').href])

こうすることで下記のような JSON がコンソールに出力される。

実行結果(例)
[
  [
    "ここに名前が入ります",
    "https://chat.line.biz/U11111111111111111111111111111111/chat/U22222222222222222222222222222222"
  ]
]

ただアカウントにチャットメッセージを送信したことがあるユーザーに限られるようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

使用する API

ユーザーリスト取得は認証済アカウントまたはプレミアムアカウントのみで利用できる。

LINE公式アカウントを友だち追加したユーザーのリストを取得する
GET https://api.line.me/v2/bot/followers/ids

https://developers.line.biz/ja/reference/messaging-api/#get-follower-ids

LINE公式アカウントを友だち追加したユーザーのリストを取得する
GET https://api.line.me/v2/bot/followers/ids

https://developers.line.biz/ja/reference/messaging-api/#get-profile

プロフィール情報を取得する
GET https://api.line.me/v2/bot/profile/{userId}

この 2 つを組み合わせればいけそう。

下記ページが参考になった、ありがとうございます。

https://www.line-community.me/ja/question/60112447851f74bb9ca201f5/line公式に友達追加されている全てのアカウントのdisplaynameユーザーの表示名を入手できるのか知りたいです

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

やったこと

プロバイダー作成とチャンネルアクセストークン(長期)発行の 2 つだけ。

プロバイダー作成は LINE for Business 管理画面からもできるので便利。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

get-user-ids.ts
import fetch from "node-fetch";

type ResponseBody = {
  userIds: string[];
  next?: string;
};

async function main() {
  const endpoint = "https://api.line.me/v2/bot/followers/ids";
  let responseBody: ResponseBody | null = null;

  for (;;) {
    const searchParams = new URLSearchParams({
      limit: "1000",
    });

    if (responseBody?.next) {
      searchParams.set("start", responseBody.next);
    }

    const response = await fetch(endpoint + "?" + searchParams.toString(), {
      headers: {
        Authorization: `Bearer ${process.env.LINE_CHANNEL_ACCESS_TOKEN}`,
      },
    });

    if (response.status !== 200) {
      console.error(await response.text());
      return;
    }

    responseBody = (await response.json()) as ResponseBody;

    for (const userId of responseBody.userIds) {
      console.log(userId);
    }

    if (!responseBody.next) {
      break;
    }
  }
}

main().catch((err) => console.error(err));
.env(例)
LINE_CHANNEL_ACCESS_TOKEN="xxxx"
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

実行

コマンド
npx ts-node -r dotenv/config get-user-ids.ts > user-ids.txt
user-ids.txt(例)
U11111111111111111111111111111111
U22222222222222222222222222222222
U33333333333333333333333333333333
...
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロフィール取得

get-profiles.ts
import { readFile } from "fs/promises";
import fetch from "node-fetch";

type ResponseBody = {
  displayName: string;
  userId: string;
  language?: string;
  pictureUrl?: string;
  statusMessage?: string;
};

async function main() {
  const lines = (await readFile("user-ids.txt", "utf-8"))
    .split("\n")
    .filter((line) => line !== "");

  for (const line of lines) {
    const endpoint = `https://api.line.me/v2/bot/profile/${line}`;
    const response = await fetch(endpoint, {
      headers: {
        Authorization: `Bearer ${process.env.LINE_CHANNEL_ACCESS_TOKEN}`,
      },
    });

    if (response.status !== 200) {
      console.error(await response.text());
    }

    const { displayName, userId, language, pictureUrl, statusMessage } =
      (await response.json()) as ResponseBody;

    console.log(
      [
        displayName,
        userId,
        language ?? "",
        pictureUrl ?? "",
        (statusMessage ?? "").replace(/\n/g, " "),
      ].join("\t")
    );
  }
}

main().catch((err) => console.error(err));
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

実行

コマンド
npx ts-node -r dotenv/config get-profiles.ts > profiles.txt
profiles.txt(例)
ここに名前が入ります	U11111111111111111111111111111111	ja	https://profile.line-scdn.net/0m11111111111111111111111111111111111111111111	
ここに名前が入ります	U22222222222222222222222222222222	ja	https://profile.line-scdn.net/0m22222222222222222222222222222222222222222222	
ここに名前が入ります	U33333333333333333333333333333333	ja	https://profile.line-scdn.net/0m33333333333333333333333333333333333333333333	
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

LINE ユーザー ID について

https://developers.line.biz/ja/docs/messaging-api/getting-user-ids/#what-is-user-id

ユーザーIDは、同じユーザーであってもプロバイダーごとに異なる値が発行されます。プロバイダーが同じであれば、チャネルの種類(LINEログインチャネルやMessaging APIチャネル)にかかわらず、同じユーザーIDが割り当てられます。

これはかなり厄介だな。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

LINE ユーザー ID についてさらに

つまりユーザー ID をエクスポートしてもプロバイダーが変わると利用できなくなる。

アカウント A でエクスポートしたユーザー ID をアカウント B で使うみたいなことは当然できない。

今回はチャネル A でエクスポートしたユーザー ID をチャネル B で使うのでセーフだった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

LINE チャットページから表示名を取得する

LINE チャットページではユーザーの表示名を自由に変更できる。

この表示名については取得するのがなかなか難しそうなので開発者ツールから無理やり取得してみる。

// タイムラインを表示する
document.querySelector('.list-group-item:nth-child(6) a div').click();
// エンピツボタンを押す
document.querySelector('#content-thirdly h3.d-inline-block a').click();
// 変更前の表示名の取得
document.querySelector('.modal-body .text-truncate-3').innerText
// 変更後の表示名の取得
document.querySelector('.modal-body input').value
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プログラムにしてみる

(async () => {
  try {
    const divs = [
      ...document.querySelectorAll(".list-group-item a > div:first-child"),
    ].slice(0, 50);
    const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
    const result = [];

    for (const div of divs) {
      // タイムラインを表示する
      div.click();

      await sleep(500);

      // エンピツボタンを押す
      document.querySelector("#content-thirdly h3.d-inline-block a").click();

      await sleep(500);

      // 変更前の表示名の取得
      const before = document.querySelector(
        ".modal-body .text-truncate-3"
      ).innerText;

      await sleep(500);

      // 変更後の表示名の取得
      const after = document.querySelector(".modal-body input").value;

      await sleep(500);

      // 閉じるボタンを押す
      document.querySelector(".modal-header button").click();

      result.push({ before, after });
    }

    console.log(result);
  } catch (err) {
    console.error(err);
  }
})();

slice() の第 2 引数を指定すると 1〜2 件で試せる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

改良してみた

get-display-names.js
(async () => {
  try {
    const divs = [
      ...document.querySelectorAll(".list-group-item a > div:first-child"),
    ].slice(0, 10);

    const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
    const findElement = async (selector, timeout, retry) => {
      timeout = timeout ?? 100;
      retry = retry ?? 5;

      for (let i = 0; i < retry; i += 1) {
        if (i > 0) {
          await sleep(timeout);
        }

        const el = document.querySelector(selector);

        if (el) {
          return el;
        }
      }

      throw new Error(`Element not found: selector = "${selector}"`);
    };

    const result = [];

    for (const div of divs) {
      // タイムラインを表示する
      div.click();

      // エンピツボタンを押す
      (await findElement("#content-thirdly h3.d-inline-block a")).click();

      // 変更前の表示名の取得
      const before = (await findElement(".modal-body .text-truncate-3"))
        .innerText;

      // 変更後の表示名の取得
      const after = (await findElement(".modal-body input")).value;

      // 閉じるボタンを押す
      (await findElement(".modal-header button")).click();

      result.push({ before, after });
    }

    console.log(result);
  } catch (err) {
    console.error(err);
  }
})();
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

上のコードは不要かも知れない

だいぶスピードアップしたが全部のユーザーを表示するのがかったるいなーと思って色々と調べているうちに下記にアクセスすれば必要なデータが JSON で取得できることがわかった。

チャットの一覧を取得する API
GET https://chat.line.biz/api/v2/bots/U00000000000000000000000000000000/chats?folderType=ALL&limit=25

ドキュメントには無さそうなのでチャットアプリのための専用 API な感じがする。

limit は最大値が 25 のようだ。

レスポンスには list は next が含まれ、next が含まれる場合は次のリクエストのクエリパラメーターとして next を含めてやれば良さそう。

Cookie をコピーすれば Node.js からも取得できるかも知れないが、Chrome の開発者コンソールから実行した方が良さそう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

レスポンスを調べてみる

公式アカウント側が設定した表示名は内部的には nickname と呼ぶようだ。

また chatId というのがあるので Messaging API で取得できる userId とは違うもののようだ。

userId というのもあるがほとんどは chatId と同じになっているので chatId と同じと考えて良さそうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チャット一覧を取得するプログラム

userId は事前に設定しておきます。

Google Chrome の開発者コンソールで実行します。

get-chats.ts
(async function () {
  const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
  const endpoint = `https://chat.line.biz/api/v2/bots/${userId}/chats`;
  const responseBodies = [];
  let responseBody = null;

  for (;;) {
    const searchParams = new URLSearchParams({
      folderType: "ALL",
      limit: "25",
    });

    if (responseBody && responseBody.next) {
      searchParams.set("next", responseBody.next);
    }

    const response = await fetch(endpoint + "?" + searchParams.toString());

    if (response.status !== 200) {
      console.error(await response.text());
      return;
    }

    responseBody = await response.json();
    responseBodies.push(responseBody);

    if (!responseBody.next) {
      break;
    }

    // 気休め
    await sleep(200);
  }

  console.log(responseBodies);
})().catch((err) => console.error(err));

他にも困っている人がいるようなので後からコメントしておこう。

https://www.line-community.me/ja/question/6494b938e723213c1b59cbee

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

情報の抽出

レスポンスから下記の情報を抽出する。

  • チャット ID
  • 表示名
  • ニックネーム
  • アイコン URL
  • 重複しているかどうか
extract-nicknames.ts
import { readFile } from "fs/promises";

async function main() {
  const text = await readFile("data-chats.json", "utf-8");
  const items = JSON.parse(text) as {
    list: {
      chatId: string;
      profile: {
        name?: string;
        nickname?: string;
        iconHash?: string;
      };
    }[];
  }[];

  const users: {
    chatId: string;
    name: string;
    nickname: string;
    icon: string;
  }[] = [];

  for (const item of items) {
    for (const listItem of item.list) {
      if (!listItem.profile?.name) {
        continue;
      }

      const chatId = listItem.chatId;
      const name = listItem.profile.name;
      const nickname = listItem.profile.nickname ?? "";
      const icon = listItem.profile.iconHash
        ? `https://profile.line-scdn.net/${listItem.profile.iconHash}/preview`
        : "";

      const user = { chatId, name, nickname, icon };

      users.push(user);
    }
  }

  const userNames = users.map((user) => user.name);
  const duplicateNames: string[] = [];

  userNames.forEach((userName, i) => {
    if (userNames.indexOf(userName) !== i) {
      duplicateNames.push(userName);
    }
  });

  const duplicateNamesSet = new Set(duplicateNames);

  console.log(
    JSON.stringify(
      users.map((user) => ({
        ...user,
        isDuplicated: duplicateNamesSet.has(user.name),
      })),
      null,
      2
    )
  );
}

main().catch((err) => console.error(err));
コマンド
npx ts-node extract-nicknames.ts > data-nicknames.json
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロフィールのデータ形式変更

今さらだけど TSV じゃなくて JSON のままにしておけばよかった。

という訳でソースコードを少し改良する。

get-profiles.ts
import { readFile } from "fs/promises";
import fetch from "node-fetch";

type ResponseBody = {
  displayName: string;
  userId: string;
  language?: string;
  pictureUrl?: string;
  statusMessage?: string;
};

async function main() {
  const lines = (await readFile("data-user-ids.txt", "utf-8"))
    .split("\n")
    .filter((line) => line !== "");

  const responseBodies = [] as any[];

  for (const line of lines) {
    const endpoint = `https://api.line.me/v2/bot/profile/${line}`;
    const response = await fetch(endpoint, {
      headers: {
        Authorization: `Bearer ${process.env.LINE_CHANNEL_ACCESS_TOKEN}`,
      },
    });

    if (response.status !== 200) {
      console.error(await response.text());
      continue;
    }

    responseBodies.push(await response.json());
  }

  console.log(JSON.stringify(responseBodies, null, 2));
}

main().catch((err) => console.error(err));
コマンド
npx ts-node -r dotenv/config get-profiles.ts > data-profiles.json
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

名寄せ?結合?

data-profiles.json と data-nicknames.json(data-chats.json の方が良かったかな?)を名寄せする。

名寄せと呼べば良いのか、結合と呼べば良いのか、定まらない。

append-nickname.ts
import { readFile } from "fs/promises";

async function main() {
  const chats: {
    chatId: string;
    name: string;
    nickname: string;
    icon: string;
    isDuplicated: boolean;
  }[] = JSON.parse(await readFile("data-nicknames.json", "utf-8"));

  const profiles: {
    userId: string;
    displayName: string;
    pictureUrl?: string;
  }[] = JSON.parse(await readFile("data-profiles.json", "utf-8"));

  const newProfiles = profiles.map((profile) => {
    const { userId, displayName, pictureUrl } = profile;
    const filteredChats = chats.filter((chat) => {
      return chat.name === profile.displayName;
    });

    if (filteredChats.length === 0) {
      return profile;
    } else if (filteredChats.length === 1) {
      return {
        ...profile,
        nickname: filteredChats[0].nickname,
      };
    } else {
      return {
        ...profile,
        candidates: filteredChats.map((chat) => ({
          chatId: chat.chatId,
          nickname: chat.nickname,
          icon: chat.icon,
        })),
      };
    }
  });

  console.log(JSON.stringify(newProfiles, null, 2));
}

main().catch((err) => console.error(err));
コマンド
npx ts-node append-nicknames.ts > data-append.json

名前が同じで判断がつかないものについては candidates をつけた。

ここまできたら写真を見て手動で判断するしかない。

ただ 123 件も重複があるのでなかなかにかったるい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

画像が使えそうだ

Messaging API と Chat API を使って出力したそれぞれのデータにはアイコンの URL が含まれており、アイコン画像をダウンロードしてハッシュを計算し、一致する場合には同一人物して扱えば良いかも知れない。

Chat API の方の URL には "/preview" が末尾にあるが、これを削除するとオリジナルサイズの画像を取得できそう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

とりあえず現状

append-nicknames.ts
import { readFile } from "fs/promises";

async function main() {
  const chats: {
    chatId: string;
    name: string;
    nickname: string;
    icon: string;
    isDuplicated: boolean;
  }[] = JSON.parse(await readFile("data-nicknames.json", "utf-8"));

  const profiles: {
    userId: string;
    displayName: string;
    pictureUrl?: string;
  }[] = JSON.parse(await readFile("data-profiles.json", "utf-8"));

  const mappings = (await readFile("mappings.txt", "utf-8"))
    .split("\n")
    .filter((line) => line !== "")
    .map((line) => {
      const [userId, chatId] = line.split(" ");
      return { userId, chatId };
    });

  const newProfiles = profiles.map((profile) => {
    const filteredChats = chats.filter((chat) => {
      return chat.name === profile.displayName;
    });

    if (filteredChats.length === 0) {
      return profile;
    } else if (filteredChats.length === 1) {
      return {
        ...profile,
        nickname: filteredChats[0].nickname,
      };
    } else {
      const filteredMappings = mappings.filter((mapping) => {
        return mapping.userId === profile.userId;
      });

      if (filteredMappings.length === 0) {
        return {
          ...profile,
          candidates: filteredChats.map((chat) => ({
            chatId: chat.chatId,
            nickname: chat.nickname,
            icon: chat.icon,
          })),
        };
      } else if (filteredMappings.length === 1) {
        const chat = filteredChats.find(
          (chat) => chat.chatId === filteredMappings[0].chatId
        );

        if (!chat) {
          throw new Error("!chat");
        }

        return {
          ...profile,
          nickname: chat.nickname,
        };
      } else {
        throw new Error("filteredMappings.length >= 2");
      }
    }
  });

  console.log(JSON.stringify(newProfiles, null, 2));
}

main().catch((err) => console.error(err));
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

画像を比較する

あらかじめ data-nicknames.json の icon から "/preview" を削除しておく。

data-append.json
import { createHash } from "crypto";
import { readFile } from "fs/promises";
import fetch from "node-fetch";

async function main() {
  const chats: {
    chatId: string;
    name: string;
    nickname: string;
    icon: string;
    isDuplicated: boolean;
  }[] = JSON.parse(await readFile("data-nicknames.json", "utf-8"));

  const profiles: {
    userId: string;
    displayName: string;
    pictureUrl?: string;
  }[] = JSON.parse(await readFile("data-profiles.json", "utf-8"));

  const mappings = (await readFile("mappings.txt", "utf-8"))
    .split("\n")
    .filter((line) => line !== "")
    .map((line) => {
      const [userId, chatId] = line.split(" ");
      return { userId, chatId };
    });

  const newProfiles = await Promise.all(
    profiles.map(async (profile) => {
      const filteredChats = chats.filter((chat) => {
        return chat.name === profile.displayName;
      });

      if (filteredChats.length === 0) {
        return profile;
      } else if (filteredChats.length === 1) {
        return {
          ...profile,
          nickname: filteredChats[0].nickname,
          chatId: filteredChats[0].chatId,
        };
      } else {
        const candidates = filteredChats.map((chat) => ({
          chatId: chat.chatId,
          nickname: chat.nickname,
          icon: chat.icon,
        }));

        if (profile.pictureUrl) {
          const pictureDigest = await getImageHash(profile.pictureUrl);

          for (const candidate of candidates) {
            if (candidate.icon !== "") {
              const iconDigest = await getImageHash(candidate.icon);

              if (pictureDigest === iconDigest) {
                return {
                  ...profile,
                  nickname: filteredChats[0].nickname,
                  chatId: filteredChats[0].chatId,
                };
              }
            }
          }
        }

        return {
          ...profile,
          candidates,
        };
      }
    })
  );

  console.log(JSON.stringify(newProfiles, null, 2));
}

async function getImageHash(url: string): Promise<string> {
  const arrayBuffer = await fetch(url).then((res) => res.arrayBuffer());
  const buffer = Buffer.from(arrayBuffer);
  return createHash("sha-256").update(buffer).digest("hex");
}

main().catch((err) => console.error(err));

これで 100 件あった重複が 10 件くらいに減った。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

最後かな?

chatId の Set を作成し、candidates の要素数を少しでも減らそう。

remove-candidates.ts
import { readFile } from "fs/promises";

async function main() {
  const users: {
    chatId?: string;
    candidates?: {
      chatId: string;
    }[];
  }[] = JSON.parse(await readFile("data-append.json", "utf-8"));
  const chatIds = users
    .filter((user) => user.chatId)
    .map((user) => user.chatId) as string[];
  const chatIdsSet = new Set(chatIds);
  const newUsers = users.map((user) => {
    if (!user.candidates) {
      return {
        ...user,
        chatUrl: getChatUrl(user.chatId),
      };
    }

    return {
      ...user,
      candidates: user.candidates
        .filter((candidate) => {
          return chatIdsSet.has(candidate.chatId);
        })
        .map((candidate) => {
          return {
            ...candidate,
            chatUrl: getChatUrl(candidate.chatId),
          };
        }),
    };
  });

  console.log(JSON.stringify(newUsers, null, 2));
}

function getChatUrl(chatId?: string): string {
  return chatId
    ? `https://chat.line.biz/${process.env.LINE_CHANNEL_USER_ID}/chat/${chatId}`
    : "";
}

main().catch((err) => console.error(err));
コマンド
npx ts-node -r dotenv/config remove-candidates.ts > data-last.json

ついでに chatUrl を付加した。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

最初から実行する。

コマンド
npx ts-node -r dotenv/config get-user-ids.ts > data-user-ids.txt
npx ts-node -r dotenv/config get-profiles.ts > data-profiles.json
# ブラウザの開発者コンソールで get-chats.js を実行して data-chats.json に保存する。
# その際に const userId = "Uxxxx"; を忘れないようにする
npx ts-node -r dotenv/config extract-nicknames.ts > data-nicknames.json
npx ts-node -r dotenv/config append-nicknames.ts > data-append.json
npx ts-node -r dotenv/config remove-candidates.ts > data-last.json

なんかおかしいと思ったら /preview を消すのを忘れていた。

再度実行してみたけどやはり 10 件ちょっとくらいは candidates が残っている。

ただ candidates も 1 件しか無いのでほとんど絞られていると言っても過言ではない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

形を整えてみる

タブ区切りにして Excel にコピペできるようにしてみよう。

export-tsv.ts
import { readFile } from "fs/promises";

async function main() {
  const users: any[] = JSON.parse(await readFile("data-last.json", "utf-8"));

  const headerCells = [
    "LINE ID",
    "お名前",
    "お客さまが設定したお名前",
    "チャットページのURL",
    // "【候補】お客さまが設定したお名前",
    // "【候補】チャットページのURL",
  ];

  console.log(headerCells.join("\t"));

  for (const user of users) {
    const { userId, displayName, nickname, chatUrl } = user;
    const cells = [userId, nickname, displayName, chatUrl];

    if (!nickname) {
      continue;
    }

    if (user.candidates) {
      for (const candidate of user.candidates) {
        cells.push(candidate.nickname);
        cells.push(candidate.chatUrl);
      }
    }

    console.log(cells.join("\t"));
  }
}

main().catch((err) => console.error(err));
コマンド
npx ts-node -r dotenv/config export-tsv.ts > data-export.json

ややこしいのでニックネームがない場合は取り除くことにした。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

おわりに

LINE ID とプロフィールのエクスポートは API が用意されていたので簡単だった。

一方、ニックネーム(チャットページでこちら側で設定した名前)については結構大変だった。

たまたま隠し API を見つけられたから良かったが、そうじゃなかったらかなり大変だっただろう。

かなりとっ散らかってしまったのでどうにか機会を作って整理して記事にしたい。

とりあえずこの「おわりに」を書いたら下記の記事にコメントしておこう。

https://www.line-community.me/ja/question/6494b938e723213c1b59cbee

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コメントの写し

Mako さん、はじめまして。
プログラマーの薄田と申します。

残念ながら Messaging APIで取得できるデータの中に店舗側が設定した「ユーザーの表示名」は含まれないようです。
ただし方法が無い訳ではなく、まだまだ不完全ながら解決方法を見つけましたので共有いたします。

もう 1 カ月以上経っており別の方法で解決されたかも知れませんが、
他の方のお役に立てばと思い回答させていただきます。

解決の過程については下記の記事にまとめてました。
https://zenn.dev/tatsuyasusukida/scraps/ab9bf2326b4fce

ポイント

下記の API を使用して店舗側で設定した「ユーザーの表示名」を取得することができます。

GET https://chat.line.biz/api/v2/bots/U00000000000000000000000000000000/chats?folderType=ALL&limit=25

U0000... のところにはチャットページに表示される U1234... みたいな文字列が入ります。
ドキュメントについては僕が少し探したところ見つからなかったので隠し API かも知れません。
したがって今と将来とでは仕様が変化する可能性があるのでご注意ください。

手順

下記の通りです。

  1. LINE 公式アカウントの管理画面にログインしてチャットページへ移動する。
  2. ブラウザの開発者ツールを使って下記の JavaScript コードを実行する。
(async function () {
  const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
  const endpoint = `https://chat.line.biz/api/v2/bots/${userId}/chats`;
  const responseBodies = [];
  let responseBody = null;

  for (;;) {
    const searchParams = new URLSearchParams({
      folderType: "ALL",
      limit: "25",
    });

    if (responseBody && responseBody.next) {
      searchParams.set("next", responseBody.next);
    }

    const response = await fetch(endpoint + "?" + searchParams.toString());

    if (response.status !== 200) {
      console.error(await response.text());
      return;
    }

    responseBody = await response.json();
    responseBodies.push(responseBody);

    if (!responseBody.next) {
      break;
    }

    // 気休め
    await sleep(200);
  }

  console.log(responseBodies);
})().catch((err) => console.error(err));

しばらくするとコンソールに実行結果が出力されるのでコピーします。
実行結果の中に nickname という名称で店舗側が設定した「ユーザーの表示名」が含まれています。

userId が異なる点について

実行結果には userId も含まれているのですが、この userId は Messaging API で取得できる API とは異なるようです。
したがって Messaging API の userId + 店舗側が設定した「ユーザーの表示名」の対応表が欲しい場合には名寄せ作業が必要になります。

この名寄せ作業が結構大変なのですが僕の場合は下記のようにユーザーの同一性を判断しました。

  1. 友だちが設定した表示名が同じであれば同じユーザーと判断する。
  2. アイコン画像が同じであれば同じユーザーと判断する。

Messaging API で出力されたユーザーは 1,350 件で、最終的に残ったのは 1,064 件でした。

GitHub リポジトリ

下記のプログラムが含まれた GitHub リポジトリを作成しましたのでお役に立つようであればご活用ください。

  • Messaging API を使用したユーザー ID&プロフィールの出力
  • 隠し API を使用した店舗側で設定した「ユーザーの表示名」の出力
  • 名寄せ作業

https://github.com/tatsuyasusukida/line-friend-export

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

append-nicknames.ts の修正

よく考えたら一致する名前が 1 つしか無いからと言ってそれが正しいとは限らない。

基本的にはすべてについて画像を比較する必要がある。

また、map + async を使うと並列で画像取得リクエストが何件も送信されるので時々失敗する。

これらの点を改良した。

append-nicknames.ts
import { createHash } from "crypto";
import { readFile } from "fs/promises";
import fetch from "node-fetch";

async function main() {
  const chats: {
    chatId: string;
    name: string;
    nickname: string;
    icon: string;
    isDuplicated: boolean;
  }[] = JSON.parse(await readFile("data-nicknames.json", "utf-8"));

  type Profile = {
    userId: string;
    displayName: string;
    pictureUrl?: string;
  };

  type Candidate = {
    chatId: string;
    nickname: string;
    icon: string;
  };

  const profiles: Profile[] = JSON.parse(
    await readFile("data-profiles.json", "utf-8")
  );

  const newProfiles: (
    | Profile
    | { nickname?: string; chatId?: string; candidates?: Candidate[] }
  )[] = [];

  for (const profile of profiles) {
    const chatsWithSameName = chats.filter((chat) => {
      return chat.name === profile.displayName;
    });

    if (chatsWithSameName.length === 0) {
      newProfiles.push(profile);
      continue;
    }

    const candidates = chatsWithSameName.map((chat) => ({
      chatId: chat.chatId,
      nickname: chat.nickname,
      icon: chat.icon,
    }));

    newProfiles.push(
      await (async () => {
        if (profile.pictureUrl) {
          const pictureDigest = await getImageHash(profile.pictureUrl);

          for (const candidate of candidates) {
            if (candidate.icon !== "") {
              const iconDigest = await getImageHash(candidate.icon);

              if (pictureDigest === iconDigest) {
                return {
                  ...profile,
                  nickname: chatsWithSameName[0].nickname,
                  chatId: chatsWithSameName[0].chatId,
                };
              }
            }
          }
        }

        return {
          ...profile,
          candidates,
        };
      })()
    );
  }

  console.log(JSON.stringify(newProfiles, null, 2));
}

async function getImageHash(url: string): Promise<string> {
  const arrayBuffer = await fetch(url).then((res) => res.arrayBuffer());
  const buffer = Buffer.from(arrayBuffer);
  return createHash("sha-256").update(buffer).digest("hex");
}

main().catch((err) => console.error(err));
このスクラップは2023/08/03にクローズされました