twitter.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import * as emojiStrip from 'emoji-strip';
  2. import * as fs from 'fs';
  3. import * as path from 'path';
  4. import * as Twitter from 'twitter';
  5. import TwitterTypes from 'twitter-d';
  6. import { XmlEntities } from 'html-entities';
  7. import QQBot from './koishi';
  8. import { getLogger } from './loggers';
  9. import { BigNumOps, chainPromises } from './utils';
  10. import Wiki from './wiki';
  11. interface IWorkerOption {
  12. lock: ILock;
  13. lockfile: string;
  14. bot: QQBot,
  15. workInterval: number;
  16. wikiSessionCookie: string;
  17. consumerKey: string;
  18. consumerSecret: string;
  19. accessTokenKey: string;
  20. accessTokenSecret: string;
  21. }
  22. export interface ITimelineQueryConfig {
  23. username: string;
  24. count?: number;
  25. since?: string;
  26. until?: string;
  27. noreps?: boolean;
  28. norts?: boolean;
  29. }
  30. export const keywordMap = {
  31. 'ガチャ.*予告': '卡池',
  32. '(選べる.*|一日一回無料|スタートダッシュ.*)ガチャ': '卡池',
  33. 'イベント開催決定|フォトオーディション開催': '活动',
  34. 'タワー.*追加': '新塔',
  35. '(生放送|配信)予告': '生放',
  36. 'メンテナンス予告': '维护',
  37. '(育成応援|お仕事).*開催': '工作',
  38. '新曲一部公開|3DLIVE映像公開': '新曲',
  39. 'キャラクター紹介': '组合',
  40. '今後のアップデート情報': '计划',
  41. '(?<!今後の)アップデート情報': '改修',
  42. };
  43. export const recurringKeywords = [
  44. '(育成応援|お仕事|フォトオーディション).*開催', 'イベント開催決定', 'ガチャ$|フェスガチャ',
  45. 'アップデート情報', 'キャラクター紹介',
  46. '(生放送|配信)予告', 'メンテナンス予告'
  47. ];
  48. const xmlEntities = new XmlEntities();
  49. export const processTweetBody = (tweet: Tweet) => {
  50. const urls = tweet.entities.urls ? tweet.entities.urls.map(url => url.expanded_url) : [];
  51. const [title, body] = emojiStrip(xmlEntities.decode(tweet.full_text))
  52. .replace(/(?<=\n|^)(.*)(?:\: |:)(https?:\/\/.*?)(?=\s|$)/g, '[$2 $1]')
  53. .replace(/https?:\/\/.*?(?=\s|$)/g, () => urls.length ? urls.splice(0, 1)[0] : '')
  54. .replace(/(?<=\n|^)[\/]\n/g, '')
  55. .replace(/((?<=\s)#.*?\s+)+$/g, '')
  56. .trim()
  57. .match(/(.*?)\n\n(.*)/s).slice(1);
  58. const dateMatch = /(\d+\/\d+)\(.\).*(?:から|~)\n/.exec(body);
  59. const date = (dateMatch ?
  60. new Date(`${new Date(tweet.created_at).getFullYear()}/${dateMatch[1]}`) :
  61. new Date(tweet.created_at)
  62. )
  63. .toLocaleDateString('en-CA', {timeZone: 'Asia/Tokyo'})
  64. .replace(/-/g, '');
  65. const pageTitle = title.replace(/[【】\/\n]/g, '') + `${recurringKeywords.some(
  66. keyword => new RegExp(keyword).exec(title)
  67. ) ? `-${date}` : ''}`;
  68. return {title, body, pageTitle, date};
  69. };
  70. export const parseAction = (action: WikiEditResult) => {
  71. if (!action || !Object.keys(action)) return '(无)';
  72. return `\n
  73. 标题:${action.title}
  74. 操作:${action.new ? '新建' : '更新'}
  75. 媒体:${action.mediafiles.map((fileName, index) => `\n ${index + 1}. ${fileName}`).join('') || '(无)'}
  76. 链接:${action.pageid ? `https://wiki.biligame.com/idolypride/index.php?curid=${action.pageid}` : '(无)'}
  77. `;
  78. }
  79. const TWITTER_EPOCH = 1288834974657;
  80. export const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
  81. BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
  82. const logger = getLogger('twitter');
  83. export type FullUser = TwitterTypes.FullUser;
  84. export type Entities = TwitterTypes.Entities;
  85. export type ExtendedEntities = TwitterTypes.ExtendedEntities;
  86. export type MediaEntity = TwitterTypes.MediaEntity;
  87. interface ITweet extends TwitterTypes.Status {
  88. user: FullUser;
  89. retweeted_status?: Tweet;
  90. }
  91. export type Tweet = ITweet;
  92. export type Tweets = ITweet[];
  93. export default class {
  94. private client: Twitter;
  95. private bot: QQBot;
  96. private publisher: Wiki;
  97. private lock: ILock;
  98. private lockfile: string;
  99. private workInterval: number;
  100. private wikiSessionCookie: string;
  101. constructor(opt: IWorkerOption) {
  102. this.client = new Twitter({
  103. consumer_key: opt.consumerKey,
  104. consumer_secret: opt.consumerSecret,
  105. access_token_key: opt.accessTokenKey,
  106. access_token_secret: opt.accessTokenSecret,
  107. });
  108. this.bot = opt.bot;
  109. this.publisher = new Wiki(opt.lock);
  110. this.lockfile = opt.lockfile;
  111. this.lock = opt.lock;
  112. this.workInterval = opt.workInterval;
  113. this.wikiSessionCookie = opt.wikiSessionCookie;
  114. }
  115. public launch = () => {
  116. this.publisher.login(this.wikiSessionCookie).then(() => {
  117. setTimeout(this.work, this.workInterval * 1000)
  118. });
  119. }
  120. public work = () => {
  121. const lock = this.lock;
  122. if (this.workInterval < 1) this.workInterval = 1;
  123. const currentFeed = 'https://twitter.com/idolypride';
  124. logger.debug(`pulling feed ${currentFeed}`);
  125. const promise = new Promise(resolve => {
  126. let config: {[key: string]: any};
  127. let endpoint: string;
  128. const match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
  129. if (match) {
  130. config = {
  131. screen_name: match[1],
  132. exclude_replies: true,
  133. include_rts: false,
  134. tweet_mode: 'extended',
  135. };
  136. endpoint = 'statuses/user_timeline';
  137. }
  138. if (endpoint) {
  139. const offset = lock.offset;
  140. if (offset as unknown as number > 0) config.since_id = offset;
  141. const getMore = (lastTweets: Tweets = []) => this.client.get(
  142. endpoint, config, (error: {[key: string]: any}[], tweets: Tweets
  143. ) => {
  144. if (error) {
  145. if (error instanceof Array && error.length > 0 && error[0].code === 34) {
  146. logger.warn(`error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
  147. lock.subscribers.forEach(subscriber => {
  148. logger.info(`sending notfound message of ${currentFeed} to ${JSON.stringify(subscriber)}`);
  149. this.bot.sendTo(subscriber, `错误:链接 ${currentFeed} 指向的用户或列表不存在。`).catch();
  150. });
  151. } else {
  152. logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
  153. }
  154. }
  155. if (!(tweets instanceof Array) || tweets.length === 0) return resolve(lastTweets);
  156. if (offset as unknown as number <= 0) return resolve(lastTweets.concat(tweets));
  157. config.max_id = BigNumOps.plus(tweets.slice(-1)[0].id_str, '-1');
  158. getMore(lastTweets.concat(tweets));
  159. });
  160. getMore();
  161. }
  162. });
  163. promise.then((tweets: Tweets) => {
  164. logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
  165. const updateDate = () => lock.updatedAt = new Date().toString();
  166. if (tweets.length === 0) { updateDate(); return; }
  167. const updateOffset = (offset: string) => lock.offset = offset;
  168. if (lock.offset === '-1') { updateOffset(tweets[0].id_str); return; }
  169. if (lock.offset === '0') tweets.splice(1);
  170. return chainPromises(tweets.slice(0).reverse().map(tweet => () => {
  171. const match = /(.*?)\n\n(.*)/s.exec(tweet.full_text);
  172. if (match) for (const keyword in keywordMap) {
  173. if (new RegExp(keyword).exec(match[1])) {
  174. const tweetUrl = `${currentFeed}/status/${tweet.id_str}`;
  175. logger.info(`working on ${tweetUrl}`);
  176. return this.publisher.post(tweet, keywordMap[keyword])
  177. .then(action => {
  178. if (action.result === 'Success') {
  179. this.lock.lastActions.push(action);
  180. logger.info(`successfully posted content of ${tweetUrl} to bwiki, link:`);
  181. logger.info(`https://wiki.biligame.com/idolypride/index.php?curid=${action.pageid}`)
  182. const message = `已更新如下页面:${parseAction(action)}`;
  183. return Promise.all(
  184. this.lock.subscribers.map(subscriber => this.bot.sendTo(subscriber, message))
  185. ).then(() => { updateDate(); updateOffset(tweet.id_str); return true; });
  186. }
  187. });
  188. }
  189. }
  190. return Promise.resolve(false);
  191. })).then(updated => { if (updated) return; updateDate(); updateOffset(tweets[0].id_str); });
  192. })
  193. .then(() => {
  194. let timeout = this.workInterval * 1000;
  195. if (timeout < 1000) timeout = 1000;
  196. fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
  197. setTimeout(() => {
  198. this.work();
  199. }, timeout);
  200. });
  201. };
  202. }