(無料)Twitter API v2 を用いたブックマークの取得
はじめに
Twitter API v2 を用いて、自分のブックマークの棚卸しシステムを作りました。
データは、NeDB を使って保存しています。
OAuth2 については今回必要なんですが、本旨とは関係ないので割愛します。
パッケージとして、twitte-api-v2を利用します。
今回はベアラートークンを利用していて、ユーザーごとの読み取りには制限がかからないので、無料枠の API でも利用できます。
なぜブックマークなのかというと、ユーザーアーカイブを DL しても入ってなかったからです。
パッケージ
nedb-promises は、NeDB の Promise 対応版のスーパーセットです。
レートリミットについて
Twitter API では、各 API エンドポイントに対してレートリミット(15 分間あたりのリクエスト数)が設定されています。
今回はコレを考慮しながら実装していきます。今回使うエンドポイントに関して、レートリミットは以下のようになっています。
エンドポイント | レートリミット |
---|---|
GET /2/users/:id/bookmarks | 800 |
GET /2/tweets/:id | 900 |
DELETE /2/users/:id/bookmarks/:tweet_id | 50 |
このうち、ブックマークを解除する API はレートリミットが低いです。
取得できるブックマークは最新 800 個です。
そのため、800 を超えるブックマークを解除する場合かなり時間がかかります。
ソースコード
//-----------------EXP-----------------
const fs = require('fs');
const Datastore = require('nedb-promises');
const userDB = Datastore.create('db/users.db');
userDB.load();
const mediaDB = Datastore.create('db/media.db');
mediaDB.load();
const bookmarkDB = Datastore.create('db/bookmarks.db');
bookmarkDB.load();
const bookmarkDB2 = Datastore.create('db/bookmarks2.db');
bookmarkDB2.load();
const OutBookmarksDB = Datastore.create('db/OutBookmarks.db');
OutBookmarksDB.load();
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
const { setTimeout } = require('timers/promises');
const loading = require('loading-cli');
//-----------------Twitter-----------------
const { TwitterApi } = require('twitter-api-v2');
let config2 = JSON.parse(fs.readFileSync('./config2.json', 'utf8'));
let bearerToken = config2.access_token;
let refreshToken = config2.refresh_token;
let client = new TwitterApi(bearerToken);
/* const client = new TwitterApi({
clientId: config.client_id,
}); */
//-----------------Functions-----------------
const lookupBookmarks2 = async () => {
let idarray = [];
const load = loading('1: ブックマーク取得開始').start();
try {
const options = {
expansions: [
'referenced_tweets.id',
'author_id',
'attachments.media_keys',
],
'media.fields': [
'media_key',
'preview_image_url',
'type',
'url',
'public_metrics',
'non_public_metrics',
'organic_metrics',
'promoted_metrics',
'alt_text',
'variants',
],
max_results: 100,
};
let bookmarks = await client.v2.bookmarks(options);
const { meta } = bookmarks._realData;
if (meta.result_count === 0) {
load.fail('1: ブックマーク取得失敗(result_count=0)');
process.exit(1);
}
let state = 1;
do {
const { users, media } = bookmarks._realData.includes;
if (users) await userDB.insert(users);
if (media) await mediaDB.insert(media);
load.text = 'ブックマーク取得(bookmarks.db)';
let datas = bookmarks._realData.data;
datas.forEach((data) => {
idarray.push(data.id);
});
if (!bookmarks.done) {
await bookmarks.fetchNext();
} else {
state = 0;
}
} while (state);
} catch (e) {
load.fail('1: ブックマーク取得失敗');
console.error(e);
}
load.succeed('1: ブックマーク取得完了' + idarray.length + '件');
return idarray;
};
const updateBookmarks = async (ids) => {
const load = loading('取得中...').start();
await getTweetsWithTimeBounce(ids, 0, bookmarkDB2, load);
return;
};
const filterNedbsId = (doc) => {
return {
id: doc.id,
text: doc.text,
author_id: doc.author_id,
attachments: doc.attachments,
};
};
const detaSave = async (prevDB, finalDB) => {
const prevDoc = await prevDB.find({});
await finalDB.insert(prevDoc.map(filterNedbsId));
return;
};
const unBookmark2 = async (db) => {
let load = loading('ブックマーク削除開始').start();
const data = await db.find({});
const ids = data.map((data) => data.id);
let count = 0;
let cnt = 1;
for (const id of ids) {
load.text = 'ブックマーク削除中... (' + cnt + '/' + ids.length + ')';
if (count >= 50) {
load = load.succeed(
'レート制限により停止しました。15分待機します...(' +
cnt +
'/' +
ids.length +
')'
);
await sleepLoadingMain(15, load.start());
refreshBearerToken();
load.start('ブックマーク削除再開(' + cnt + '/' + ids.length + ')');
count = 0;
}
//idから、apiを呼び出しブックマークを削除する(以下の式は一つのidに対しての処理)
while (true) {
try {
let result = await client.v2.deleteBookmark(id);
if (result.data.bookmarked) {
load = loading('削除失敗(' + id + ')').warn();
}
cnt++;
count++;
break;
} catch (e) {
count = 0;
load = load.succeed(
'レート制限により停止しました。(' +
cnt +
'/' +
ids.length +
')'
);
await sleepLoadingMain(15, load.start());
await refreshBearerToken();
}
}
}
await db.removeMany({});
db.load();
load.succeed('3: ブックマーク削除完了');
return;
};
const refreshBearerToken = async () => {
const subClient = new TwitterApi({
clientId: config.client_id,
clientSecret: config.client_secret,
});
const {
client: refreshed,
accessToken,
refreshToken: newRefreshToken,
} = await subClient.refreshOAuth2Token(refreshToken);
bearerToken = accessToken;
refreshToken = newRefreshToken;
client = new TwitterApi(bearerToken);
fs.writeFileSync(
'./config2.json',
JSON.stringify({
access_token: bearerToken,
refresh_token: refreshToken,
}),
'utf8'
);
};
const main = async () => {
const result = await lookupBookmarks2();
await updateBookmarks(result);
await detaSave(bookmarkDB2, OutBookmarksDB);
await unBookmark2(bookmarkDB2);
return;
};
const system = async () => {
let i = 1;
while (i < 100) {
console.log(i);
try {
await main();
await sleepLoadingMain(5, loading('system待機中...').start());
} catch (e) {
console.error(e);
process.exit(0);
}
i++;
}
};
//-----------------Main-------------------
system();
//-----------------CommonLevelFunctions-----------------
const getTweets = async (ids) => {
//ids:Array of tweet id[String] MAX = about 100;
//リクエストヘッダーの制限により、100件以上のツイートを取得できない。
//return Promise
const rt = await client.v2.tweets(ids, {
'tweet.fields': ['created_at', 'attachments', 'author_id'],
});
return rt;
};
const getTweetsWithTimeBounce = async (ids, start, db, load = null) => {
let end = start + 100;
let tweets;
load.text = '取得中...' + start + '-' + end + '/' + ids.length;
if (end > ids.length) {
end = ids.length;
}
try {
tweets = await getTweets(ids.slice(start, end));
} catch (err) {
if (err.code === 429) {
load = load
.succeed(
'レート制限により停止しました。15分待機します...(' +
start +
'-' +
end +
'/' +
ids.length +
')'
)
.start('待機');
await sleepLoadingMain(15, load);
tweets = await getTweets(ids.slice(start, end));
}
}
load = load
.succeed('取得完了 (' + tweets.data.length + '件)')
.start('データ挿入開始');
await bookmarkDB2.insert(tweets.data);
if (end < ids.length) {
load = load.succeed('取得中...' + end + '/' + ids.length);
await getTweetsWithTimeBounce(ids, end, db, load);
} else {
load.succeed('2: 取得完了' + '(取得長:' + ids.length + ')');
}
return;
};
const sleepLoadingMain = async (minutes, load) => {
let m = minutes;
load.text = 'レート制限により一時停止中...(残り' + m + '分)';
while (true) {
await setTimeout(60000);
m--;
load.text = 'レート制限により一時停止中...(残り' + m + '分)';
if (m <= 0) {
load = load.succeed('待機終了(' + minutes + '分)');
break;
}
}
return;
};
{
"api_key": "",
"api_key_secret": "",
"bearer_token": "",
"access_token": "",
"access_token_secret": "",
"client_id": "",
"client_secret": "",
}
{
"access_token": "bearer_token",
"refresh_token": "refresh_token"
}
ゴミクソコードですが、一応動きます。
system()は、削除しながら、ブックマークを取得し続ける関数です。
取得できるブックマークが 0 になるまで動きます。
config,config2 と分かれている理由としては、処理中のトークンを config2 に保存し、API クライアント、アプリとしての設定は config に保存すると言ったように住み分けるためです。完全に自分の趣味ですので自由に変えても処理は変わりません。
取得結果
待機時間が長いため、待機中などに動いているか不安にならないよう cli にロード中みたいなやつを出してます。
使えばわかりますが「待機中(後 2 分)」みたいにでます。
注意
-
このコードは、TwitterAPI のレート制限に引っ掛かったら、15 分待機するようになっています。
-
取得できるブックマークが 0 になっても、アプリで見ると残っていることがあります。これは API 側の問題なので、手動でブックマークし直して API に認識させる必要があります。
-
ベアラートークンの更新を行う関数に関しては、ベアラートークン取得時のスコープに offline_access を含む必要があります。
終わり
暇人なので質問等ありましたら、コメントか Twitter に DM ください。
(3/1)追記 TypeScript で書き直しました
糞コードからまだマシになりました。
//-----------------modules-------------------------
import TwitterAPI, {
TweetV2PaginableTimelineParams,
TweetV2,
} from 'twitter-api-v2';
import fs = require('fs');
import DataStore = require('nedb-promises');
import loading, { Loading } from 'loading-cli';
const { setTimeout } = require('timers/promises');
const { saveAsJson } = require('./myUtil');
//-----------------interfaces----------------------
interface config {
api_key: string;
api_key_secret: string;
bearer_token: string;
access_token: string;
access_token_secret: string;
client_id: string;
client_secret: string;
discord_bot_token: string;
}
interface config2 {
token_type?: string;
expires_in?: number;
access_token: string;
scope?: string[];
refresh_token: string;
expires_at?: number;
}
interface tweet {
id: string;
text: string;
author_id: string;
created_at: string;
attachments?: { media_keys: string[] }[];
edit_history_tweet_ids?: string[];
}
type lookupFunc = () => Promise<string[]>; //lookupBookmarks2
type updateFunc = (ids: string[]) => Promise<void>; //updateBookmarks
type ins = (tweets: TweetV2[], db: DataStore<{ _id: string }>) => Promise<void>; //insertTweets
//-----------------datastores----------------------
//const db: DataStore<{ _id: string }> = DataStore.create('./db/bookmarks.db');
const userDB = DataStore.create('./db/users.db');
const mediaDB = DataStore.create('./db/media.db');
const db = DataStore.create('./db/bookmarks.db');
//-----------------config--------------------------
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8')) as config;
const config2 = JSON.parse(
fs.readFileSync('./config2.json', 'utf8')
) as config2;
let bearerToken = config2.access_token;
let refreshToken = config2.refresh_token;
let client = new TwitterAPI(bearerToken);
//-----------------develog-------------------------
const sampleIds = JSON.parse(
fs.readFileSync('./JSON/ids.json', 'utf-8')
) as string[];
//-----------------functions-----------------------
const refreshBearerToken = async () => {
const load = loading('Refreshing Bearer Token').start();
const subClient = new TwitterAPI({
clientId: config.client_id,
clientSecret: config.client_secret,
});
const {
client: refreshed,
accessToken,
refreshToken: newRefreshToken,
} = await subClient.refreshOAuth2Token(refreshToken);
bearerToken = accessToken;
refreshToken = newRefreshToken || refreshToken;
client = new TwitterAPI(bearerToken);
fs.writeFileSync(
'./config2.json',
JSON.stringify({
access_token: bearerToken,
refresh_token: refreshToken,
}),
'utf-8'
);
load.succeed('Bearer Token Refreshed');
};
const lookupBookmarks: lookupFunc = async () => {
let idarray: string[] = [];
const load = loading('1: ブックマーク取得開始').start();
try {
const options: Partial<TweetV2PaginableTimelineParams> = {
expansions: [
'referenced_tweets.id',
'author_id',
'attachments.media_keys',
],
'media.fields': [
'media_key',
'preview_image_url',
'type',
'url',
'public_metrics',
'non_public_metrics',
'organic_metrics',
'alt_text',
'variants',
],
max_results: 100,
};
let bookmarks = await client.v2.bookmarks(options);
const meta = bookmarks.meta;
if (meta.result_count === 0) {
load.fail('1: ブックマーク取得失敗(result_count=0)');
process.exit(1);
}
let state = 1;
do {
const users = bookmarks.includes.users;
const media = bookmarks.includes.media;
if (users) await userDB.insert(users);
if (media) await mediaDB.insert(media);
load.text = 'ブックマーク取得(bookmarks.db)';
let data = bookmarks.tweets;
const ids = data.map((tweet) => tweet.id);
idarray = idarray.concat(ids);
if (!bookmarks.done) {
await bookmarks.fetchNext();
} else {
state = 0;
}
} while (state);
} catch (e) {
load.fail('1: ブックマーク取得失敗');
console.error(e);
}
load.succeed('1: ブックマーク取得完了');
return idarray;
};
const sleepLoading = async (minutes: number, load: Loading) => {
let m = minutes;
load.text = `次の処理まで${m}分待機中`;
while (true) {
await setTimeout(1000 * 60);
m -= 1;
load.text = `次の処理まで${m}分待機中`;
if (m === 0) {
load.succeed('待機終了');
break;
}
}
};
const getTweets = async (ids: string[]) => {
let load = loading('2: ツイート取得開始').start();
if (ids.length === 0) {
load.fail('2: ツイート取得失敗(ids.length=0)');
process.exit(1);
}
let tweets: TweetV2[] = [];
const options: Partial<TweetV2PaginableTimelineParams> = {
'tweet.fields': ['created_at', 'attachments', 'author_id'],
};
if (ids.length > 100) {
const ids2d = ids.reduce((acc, cur, i) => {
const idx = Math.floor(i / 100);
const last = acc[idx];
if (last) {
last.push(cur);
} else {
acc[idx] = [cur];
}
return acc;
}, [] as string[][]);
for (const ids of ids2d) {
while (true) {
try {
tweets = tweets.concat(
(await client.v2.tweets(ids, options)).data
);
break;
} catch (e: any) {
load = load.succeed('レートリミット到達');
await sleepLoading(15, load.start());
refreshBearerToken();
load.start('2: ツイート取得開始');
continue;
}
}
}
} else {
tweets = tweets.concat((await client.v2.tweets(ids, options)).data);
}
load.succeed('2: ツイート取得完了');
return tweets;
};
const insertWithoutDep: ins = async (
tweets: TweetV2[],
db: DataStore<{ _id: string }>
) => {
let count = 1,
nodup = 0;
const load = loading('3: ツイート挿入開始').start();
for (const tweet of tweets) {
const now = '挿入中...(' + count + '/' + tweets.length + ')';
load.text = now;
const dep = await db.find({ id: tweet.id });
if (dep.length === 0) {
await db.insert(tweet);
nodup++;
}
count++;
}
count--;
load.succeed('3:挿入完了(' + nodup + '/' + count + ')');
return;
};
const unBookmark = async (ids: string[]) => {
let load = loading('4:ブックマーク削除開始').start();
let count = 0;
let cnt = 1;
for (const id of ids) {
load.text = '4:ブックマーク削除中... (' + cnt + '/' + ids.length + ')';
if (count >= 50) {
load = load.succeed(
'レート制限により停止しました。15分待機します...(' +
cnt +
'/' +
ids.length +
')'
);
await sleepLoading(15, load.start());
await refreshBearerToken();
load.start('ブックマーク削除再開(' + cnt + '/' + ids.length + ')');
count = 0;
}
while (true) {
try {
let result = await client.v2.deleteBookmark(id);
if (result.data.bookmarked) {
load = loading('削除失敗(' + id + ')').warn();
}
cnt++;
count++;
break;
} catch (e) {
count = 0;
load = load.succeed(
'レート制限により停止しました。(' +
cnt +
'/' +
ids.length +
')'
);
await sleepLoading(15, load.start());
await refreshBearerToken();
load.start(
'ブックマーク削除再開(' + cnt + '/' + ids.length + ')'
);
}
}
}
load.succeed('4: ブックマーク削除完了');
return;
};
const main = async () => {
const ids = await lookupBookmarks();
const tweets = await getTweets(ids);
await insertWithoutDep(tweets, db);
await unBookmark(ids);
return;
};
main();
Discussion