123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- import * as emojiStrip from 'emoji-strip';
- import * as fs from 'fs';
- import * as path from 'path';
- import * as Twitter from 'twitter';
- import TwitterTypes from 'twitter-d';
- import { XmlEntities } from 'html-entities';
- import QQBot from './koishi';
- import { getLogger } from './loggers';
- import { BigNumOps, chainPromises } from './utils';
- import Wiki from './wiki';
- interface IWorkerOption {
- lock: ILock;
- lockfile: string;
- bot: QQBot,
- workInterval: number;
- wikiSessionCookie: string;
- consumerKey: string;
- consumerSecret: string;
- accessTokenKey: string;
- accessTokenSecret: string;
- }
- export interface ITimelineQueryConfig {
- username: string;
- count?: number;
- since?: string;
- until?: string;
- noreps?: boolean;
- norts?: boolean;
- }
- export const keywordMap = {
- 'ガチャ予告': '卡池',
- '一日一回無料ガチャ': '卡池',
- 'イベント開催決定': '活动',
- 'タワー.*追加': '新塔',
- '(生放送|配信)予告': '生放',
- 'メンテナンス予告': '维护',
- '(育成応援|お仕事).*開催': '工作',
- '新曲一部公開': '新曲',
- 'キャラクター紹介': '组合',
- '今後のアップデート情報': '计划',
- '(?<!今後の)アップデート情報': '改修',
- };
- export const recurringKeywords = [
- '(育成応援|お仕事).*開催', 'イベント開催決定', '無料ガチャ',
- 'アップデート情報', 'キャラクター紹介',
- '(生放送|配信)予告', 'メンテナンス予告'
- ];
- const xmlEntities = new XmlEntities();
- export const processTweetBody = (tweet: Tweet) => {
- const urls = tweet.entities.urls ? tweet.entities.urls.map(url => url.expanded_url) : [];
- const [title, body] = emojiStrip(xmlEntities.decode(tweet.full_text))
- .replace(/(?<=\n|^)(.*)(?:\: |:)(https?:\/\/.*?)(?=\s|$)/g, '[$2 $1]')
- .replace(/https?:\/\/.*?(?=\s|$)/g, () => urls.length ? urls.splice(0, 1)[0] : '')
- .replace(/(?<=\n|^)[\/]\n/g, '')
- .replace(/((?<=\s)#.*?\s+)+$/g, '')
- .trim()
- .match(/(.*?)\n\n(.*)/s).slice(1);
- const date = new Date(tweet.created_at);
- const formatedDate = [[date.getFullYear(), 4], [date.getMonth(), 2], [date.getDate(), 2]]
- .map(([value, padLength]) => value.toString().padStart(padLength, '0'))
- .join('');
- const pageTitle = title.replace(/[【】\/]/g, '') + `${recurringKeywords.some(
- keyword => new RegExp(keyword).exec(title)
- ) ? `-${formatedDate}` : ''}`;
- return {title, body, pageTitle, date: formatedDate};
- };
- export const parseAction = (action: WikiEditResult) => {
- if (!action || !Object.keys(action)) return '(无)';
- return `\n
- 标题:${action.title}
- 操作:${action.new ? '新建' : '更新'}
- 媒体:${action.mediafiles.map((fileName, index) => `\n ${index + 1}. ${fileName}`).join('') || '(无)'}
- 链接:${action.pageid ? `https://wiki.biligame.com/idolypride/index.php?curid=${action.pageid}` : '(无)'}
- `;
- }
- const TWITTER_EPOCH = 1288834974657;
- export const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
- BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
- const logger = getLogger('twitter');
- export type FullUser = TwitterTypes.FullUser;
- export type Entities = TwitterTypes.Entities;
- export type ExtendedEntities = TwitterTypes.ExtendedEntities;
- export type MediaEntity = TwitterTypes.MediaEntity;
- interface ITweet extends TwitterTypes.Status {
- user: FullUser;
- retweeted_status?: Tweet;
- }
- export type Tweet = ITweet;
- export type Tweets = ITweet[];
- export default class {
- private client: Twitter;
- private bot: QQBot;
- private publisher: Wiki;
- private lock: ILock;
- private lockfile: string;
- private workInterval: number;
- private wikiSessionCookie: string;
- constructor(opt: IWorkerOption) {
- this.client = new Twitter({
- consumer_key: opt.consumerKey,
- consumer_secret: opt.consumerSecret,
- access_token_key: opt.accessTokenKey,
- access_token_secret: opt.accessTokenSecret,
- });
- this.bot = opt.bot;
- this.publisher = new Wiki(opt.lock);
- this.lockfile = opt.lockfile;
- this.lock = opt.lock;
- this.workInterval = opt.workInterval;
- this.wikiSessionCookie = opt.wikiSessionCookie;
- }
- public launch = () => {
- this.publisher.login(this.wikiSessionCookie).then(() => {
- setTimeout(this.work, this.workInterval * 1000)
- });
- }
- public work = () => {
- const lock = this.lock;
- if (this.workInterval < 1) this.workInterval = 1;
- const currentFeed = 'https://twitter.com/idolypride';
- logger.debug(`pulling feed ${currentFeed}`);
- const promise = new Promise(resolve => {
- let config: {[key: string]: any};
- let endpoint: string;
- const match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
- if (match) {
- config = {
- screen_name: match[1],
- exclude_replies: false,
- tweet_mode: 'extended',
- };
- endpoint = 'statuses/user_timeline';
- }
- if (endpoint) {
- const offset = lock.offset;
- if (offset as unknown as number > 0) config.since_id = offset;
- const getMore = (lastTweets: Tweets = []) => this.client.get(
- endpoint, config, (error: {[key: string]: any}[], tweets: Tweets
- ) => {
- if (error) {
- if (error instanceof Array && error.length > 0 && error[0].code === 34) {
- logger.warn(`error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
- lock.subscribers.forEach(subscriber => {
- logger.info(`sending notfound message of ${currentFeed} to ${JSON.stringify(subscriber)}`);
- this.bot.sendTo(subscriber, `错误:链接 ${currentFeed} 指向的用户或列表不存在。`).catch();
- });
- } else {
- logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
- }
- }
- if (!(tweets instanceof Array) || tweets.length === 0) return resolve(lastTweets);
- if (offset as unknown as number <= 0) return resolve(lastTweets.concat(tweets));
- config.max_id = BigNumOps.plus(tweets.slice(-1)[0].id_str, '-1');
- getMore(lastTweets.concat(tweets));
- });
- getMore();
- }
- });
- promise.then((tweets: Tweets) => {
- logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
-
- const updateDate = () => lock.updatedAt = new Date().toString();
- if (tweets.length === 0) { updateDate(); return; }
- const topOfFeed = tweets[0].id_str;
- const updateOffset = () => lock.offset = topOfFeed;
- if (lock.offset === '-1') { updateOffset(); return; }
- if (lock.offset === '0') tweets.splice(1);
- return chainPromises(tweets.reverse().map(tweet => () => {
- const match = /(.*?)\n\n(.*)/s.exec(tweet.full_text);
- if (!match) return Promise.resolve({});
- for (const keyword in keywordMap) {
- if (new RegExp(keyword).exec(match[1])) {
- const tweetUrl = `${currentFeed}/status/${tweet.id_str}`;
- logger.info(`working on ${tweetUrl}`);
- return this.publisher.post(tweet, keywordMap[keyword])
- .then(action => {
- if (action.result === 'Success') {
- this.lock.lastActions.push(action);
- logger.info(`successfully posted content of ${tweetUrl} to bwiki, link:`);
- logger.info(`https://wiki.biligame.com/idolypride/index.php?curid=${action.pageid}`)
- const message = `已更新如下页面:${parseAction(action)}`;
- return Promise.all(
- this.lock.subscribers.map(subscriber => this.bot.sendTo(subscriber, message))
- );
- }
- })
- .then(updateDate).then(updateOffset);
- }
- }
- }));
- })
- .then(() => {
- let timeout = this.workInterval * 1000;
- if (timeout < 1000) timeout = 1000;
- fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
- setTimeout(() => {
- this.work();
- }, timeout);
- });
- };
- }
|