|
@@ -1,10 +1,21 @@
|
|
|
+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 } from './utils';
|
|
|
+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;
|
|
@@ -20,6 +31,57 @@ export interface ITimelineQueryConfig {
|
|
|
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);
|
|
@@ -39,13 +101,15 @@ interface ITweet extends TwitterTypes.Status {
|
|
|
export type Tweet = ITweet;
|
|
|
export type Tweets = ITweet[];
|
|
|
|
|
|
-export let queryByRegExp = (username: string, regexp: RegExp, queryThreshold?: number, until?: string) =>
|
|
|
- Promise.resolve<RegExpExecArray>(null);
|
|
|
-
|
|
|
export default class {
|
|
|
|
|
|
private client: Twitter;
|
|
|
- private lastQueries: {[key: string]: {match: RegExpExecArray, id: string}} = {};
|
|
|
+ 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({
|
|
@@ -54,101 +118,109 @@ export default class {
|
|
|
access_token_key: opt.accessTokenKey,
|
|
|
access_token_secret: opt.accessTokenSecret,
|
|
|
});
|
|
|
- queryByRegExp = (username, regexp, queryThreshold?, until?) => {
|
|
|
- logger.info(`searching timeline of @${username} for matches of ${regexp}...`);
|
|
|
- const normalizedUsername = username.toLowerCase().replace(/^@/, '');
|
|
|
- const queryKey = `${normalizedUsername}:${regexp.toString()}`;
|
|
|
- const isOld = (then: string, threshold = 360) => {
|
|
|
- if (!then) return true;
|
|
|
- return BigNumOps.compare(snowflake(Date.now() - threshold * 1000), then) >= 0;
|
|
|
- };
|
|
|
- if (queryKey in this.lastQueries && !isOld(this.lastQueries[queryKey].id)) {
|
|
|
- const {match, id} = this.lastQueries[queryKey];
|
|
|
- logger.info(`found match ${JSON.stringify(match)} from cached tweet of id ${id}`);
|
|
|
- return Promise.resolve(match);
|
|
|
+ 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';
|
|
|
}
|
|
|
- return this.queryTimeline({username, norts: true, until})
|
|
|
- .then(tweets => {
|
|
|
- const found = tweets.find(tweet => regexp.test(tweet.full_text));
|
|
|
- if (found) {
|
|
|
- const match = regexp.exec(found.full_text);
|
|
|
- this.lastQueries[queryKey] = {match, id: found.id_str};
|
|
|
- logger.info(`found match ${JSON.stringify(match)} in tweet of id ${found.id_str} from timeline`);
|
|
|
- return match;
|
|
|
+
|
|
|
+ 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)}`);
|
|
|
+ }
|
|
|
}
|
|
|
- const last = tweets.slice(-1)[0].id_str;
|
|
|
- if (isOld(last, queryThreshold)) return null;
|
|
|
- return queryByRegExp(username, regexp, queryThreshold, last);
|
|
|
+ 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));
|
|
|
});
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- public queryUser = (username: string) => this.client.get('users/show', {screen_name: username})
|
|
|
- .then((user: FullUser) => user.screen_name);
|
|
|
-
|
|
|
- public queryTimelineReverse = (conf: ITimelineQueryConfig) => {
|
|
|
- if (!conf.since) return this.queryTimeline(conf);
|
|
|
- const count = conf.count;
|
|
|
- const maxID = conf.until;
|
|
|
- conf.count = undefined;
|
|
|
- const until = () => BigNumOps.min(maxID, BigNumOps.plus(conf.since, String(7 * 24 * 3600 * 1000 * 2 ** 22)));
|
|
|
- conf.until = until();
|
|
|
- const promise = (tweets: ITweet[]): Promise<ITweet[]> =>this.queryTimeline(conf).then(newTweets => {
|
|
|
- tweets = newTweets.concat(tweets);
|
|
|
- conf.since = conf.until;
|
|
|
- conf.until = until();
|
|
|
- if (
|
|
|
- tweets.length >= count ||
|
|
|
- BigNumOps.compare(conf.since, conf.until) >= 0
|
|
|
- ) {
|
|
|
- return tweets.slice(-count);
|
|
|
+ getMore();
|
|
|
}
|
|
|
- return promise(tweets);
|
|
|
});
|
|
|
- return promise([]);
|
|
|
- };
|
|
|
|
|
|
- public queryTimeline = (
|
|
|
- { username, count, since, until, noreps, norts }: ITimelineQueryConfig
|
|
|
- ) => {
|
|
|
- username = username.replace(/^@?(.*)$/, '@$1');
|
|
|
- logger.info(`querying timeline of ${username} with config: ${
|
|
|
- JSON.stringify({
|
|
|
- ...(count && {count}),
|
|
|
- ...(since && {since}),
|
|
|
- ...(until && {until}),
|
|
|
- ...(noreps && {noreps}),
|
|
|
- ...(norts && {norts}),
|
|
|
- })}`);
|
|
|
- const fetchTimeline = (
|
|
|
- config = {
|
|
|
- screen_name: username.slice(1),
|
|
|
- trim_user: true,
|
|
|
- exclude_replies: noreps ?? true,
|
|
|
- include_rts: !(norts ?? false),
|
|
|
- since_id: since,
|
|
|
- max_id: until,
|
|
|
- tweet_mode: 'extended',
|
|
|
- },
|
|
|
- tweets: ITweet[] = []
|
|
|
- ): Promise<ITweet[]> => this.client.get('statuses/user_timeline', config)
|
|
|
- .then((newTweets: ITweet[]) => {
|
|
|
- if (newTweets.length) {
|
|
|
- logger.debug(`fetched tweets: ${JSON.stringify(newTweets)}`);
|
|
|
- config.max_id = BigNumOps.plus('-1', newTweets[newTweets.length - 1].id_str);
|
|
|
- logger.info(`timeline query of ${username} yielded ${
|
|
|
- newTweets.length
|
|
|
- } new tweets, next query will start at offset ${config.max_id}`);
|
|
|
- tweets.push(...newTweets);
|
|
|
- }
|
|
|
- if (!newTweets.length || count === undefined || tweets.length >= count) {
|
|
|
- logger.info(`timeline query of ${username} finished successfully, ${
|
|
|
- tweets.length
|
|
|
- } tweets have been fetched`);
|
|
|
- return tweets.slice(0, count);
|
|
|
+ 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);
|
|
|
+ }
|
|
|
}
|
|
|
- return fetchTimeline(config, tweets);
|
|
|
+ }));
|
|
|
+ })
|
|
|
+ .then(() => {
|
|
|
+ let timeout = this.workInterval * 1000;
|
|
|
+ if (timeout < 1000) timeout = 1000;
|
|
|
+ fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
|
|
|
+ setTimeout(() => {
|
|
|
+ this.work();
|
|
|
+ }, timeout);
|
|
|
});
|
|
|
- return fetchTimeline();
|
|
|
};
|
|
|
}
|