Browse Source

init search bot

Mike L 3 years ago
parent
commit
79b69deb2d
17 changed files with 274 additions and 2371 deletions
  1. 3 15
      config.example.json
  2. 14 196
      dist/command.js
  3. 33 53
      dist/koishi.js
  4. 1 1
      dist/loggers.js
  5. 11 72
      dist/main.js
  6. 0 47
      dist/redis.js
  7. 54 330
      dist/twitter.js
  8. 0 397
      dist/webshot.js
  9. 0 6
      dist/webshot_test.js
  10. 5 22
      package.json
  11. 13 212
      src/command.ts
  12. 29 55
      src/koishi.ts
  13. 9 68
      src/main.ts
  14. 0 69
      src/redis.ts
  15. 102 382
      src/twitter.ts
  16. 0 436
      src/webshot.ts
  17. 0 10
      src/webshot_test.js

+ 3 - 15
config.example.json

@@ -3,19 +3,7 @@
   "cq_ws_host": "127.0.0.1",
   "cq_ws_port": 6700,
   "cq_bot_qq": 10000,
-  "twitter_consumer_key": "",
-  "twitter_consumer_secret": "",
-  "twitter_access_token_key": "",
-  "twitter_access_token_secret": "",
-  "mode": 0,
-  "playwright_ws_spec_endpoint": "http://127.0.0.1:8080/playwright-ws.json",
-  "resume_on_start": false,
-  "work_interval": 60,
-  "webshot_delay": 10000,
-  "lockfile": "subscriber.lock",
-  "loglevel": "info",
-  "redis": true,
-  "redis_host": "127.0.0.1",
-  "redis_port": 6379,
-  "redis_expire_time": 43200
+  "twitter_private_auth_token": "",
+  "twitter_private_csrf_token": "",
+  "loglevel": "info"
 }

+ 14 - 196
dist/command.js

@@ -1,13 +1,9 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.query = exports.resendLast = exports.view = exports.unsubAll = exports.unsub = exports.list = exports.sub = exports.parseCmd = void 0;
-const fs = require("fs");
-const path = require("path");
-const datetime_1 = require("./datetime");
+exports.picSearch = exports.search = exports.parseCmd = void 0;
 const loggers_1 = require("./loggers");
 const twitter_1 = require("./twitter");
-const utils_1 = require("./utils");
-const logger = loggers_1.getLogger('command');
+const logger = (0, loggers_1.getLogger)('command');
 function parseCmd(message) {
     message = message.trim();
     message = message.replace('\\\\', '\\0x5c');
@@ -28,200 +24,18 @@ function parseCmd(message) {
     };
 }
 exports.parseCmd = parseCmd;
-function parseLink(link) {
-    let match = /twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/.exec(link) ||
-        /^([^\/?#]+)\/([^\/?#]+)$/.exec(link);
-    if (match)
-        return [match[1], `/lists/${match[2]}`];
-    match =
-        /twitter.com\/([^\/?#]+)\/status\/(\d+)/.exec(link);
-    if (match)
-        return [match[1], `/status/${match[2]}`];
-    match =
-        /twitter.com\/([^\/?#]+)/.exec(link) ||
-            /^([^\/?#]+)$/.exec(link);
-    if (match)
-        return [match[1]];
-    return;
-}
-function linkBuilder(userName, more = '') {
-    if (!userName)
-        return;
-    return `https://twitter.com/${userName}${more}`;
-}
-function linkFinder(checkedMatch, chat, lock) {
-    var _a;
-    const normalizedLink = linkBuilder(twitter_1.ScreenNameNormalizer.normalize(checkedMatch[0]), (_a = checkedMatch[1]) === null || _a === void 0 ? void 0 : _a.toLowerCase());
-    const link = Object.keys(lock.threads).find(realLink => normalizedLink === realLink.replace(/\/@/, '/').toLowerCase());
-    if (!link)
-        return [null, -1];
-    const index = lock.threads[link].subscribers.findIndex(({ chatID, chatType }) => chat.chatID === chatID && chat.chatType === chatType);
-    return [link, index];
-}
-function sub(chat, args, reply, lock, lockfile) {
-    if (chat.chatType === "temp") {
-        return reply('请先添加机器人为好友。');
-    }
-    if (args.length === 0) {
-        return reply('找不到要订阅的链接。');
-    }
-    const match = parseLink(args[0]);
-    if (!match) {
-        return reply(`订阅链接格式错误:
-示例:
-https://twitter.com/Saito_Shuka
-https://twitter.com/rikakomoe/lists/lovelive
-https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
-    }
-    let offset = '0';
-    if (match[1]) {
-        const matchStatus = /\/status\/(\d+)/.exec(match[1]);
-        if (matchStatus) {
-            offset = utils_1.BigNumOps.plus(matchStatus[1], '-1');
-            delete match[1];
-        }
-    }
-    const subscribeTo = (link, config = {}) => {
-        const { addNew = false, msg = `已为此聊天订阅 ${link}` } = config;
-        if (addNew) {
-            lock.feed.push(link);
-            lock.threads[link] = {
-                offset,
-                subscribers: [],
-                updatedAt: '',
-            };
-        }
-        lock.threads[link].subscribers.push(chat);
-        logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
-        fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-        reply(msg);
-    };
-    const [realLink, index] = linkFinder(match, chat, lock);
-    if (index > -1)
-        return reply('此聊天已订阅此链接。');
-    if (realLink)
-        return subscribeTo(realLink);
-    const [rawUserName, more] = match;
-    if (rawUserName.toLowerCase() === 'i' && /lists\/(\d+)/.exec(more)) {
-        return subscribeTo(linkBuilder('i', more), { addNew: true });
-    }
-    twitter_1.ScreenNameNormalizer.normalizeLive(rawUserName).then(userName => {
-        if (!userName)
-            return reply(`找不到用户 ${rawUserName.replace(/^@?(.*)$/, '@$1')}。`);
-        const link = linkBuilder(userName, more);
-        const msg = (offset === '0') ?
-            undefined :
-            `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
-(参见:https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html)`;
-        subscribeTo(link, { addNew: true, msg });
-    });
-}
-exports.sub = sub;
-function unsubAll(chat, args, reply, lock, lockfile) {
-    if (chat.chatType === "temp") {
-        return reply('请先添加机器人为好友。');
-    }
-    Object.entries(lock.threads).forEach(([link, { subscribers }]) => {
-        const index = subscribers.indexOf(chat);
-        if (index === -1)
-            return;
-        subscribers.splice(index, 1);
-        fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-        logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-    });
-    return reply(`已为此聊天退订所有推特链接。`);
-}
-exports.unsubAll = unsubAll;
-function unsub(chat, args, reply, lock, lockfile) {
-    if (chat.chatType === "temp") {
-        return reply('请先添加机器人为好友。');
-    }
-    if (args.length === 0) {
-        return reply('找不到要退订的链接。');
-    }
-    const match = parseLink(args[0]);
-    if (!match) {
-        return reply('链接格式有误。');
-    }
-    const [link, index] = linkFinder(match, chat, lock);
-    if (index === -1)
-        return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
-    else {
-        lock.threads[link].subscribers.splice(index, 1);
-        fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-        logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-        return reply(`已为此聊天退订 ${link}`);
-    }
-}
-exports.unsub = unsub;
-function list(chat, _, reply, lock) {
-    if (chat.chatType === "temp") {
-        return reply('请先添加机器人为好友。');
-    }
-    const links = [];
-    Object.keys(lock.threads).forEach(key => {
-        if (lock.threads[key].subscribers.find(({ chatID, chatType }) => chat.chatID === chatID && chat.chatType === chatType))
-            links.push(`${key} ${datetime_1.relativeDate(lock.threads[key].updatedAt)}`);
-    });
-    return reply('此聊天中订阅的链接:\n' + links.join('\n'));
-}
-exports.list = list;
-function view(chat, args, reply) {
+function search(chat, args, reply) {
     if (args.length === 0 || !args[0]) {
-        return reply('找不到要查看的链接或表达式。');
-    }
-    const match = /^(last(?:|-(\d+))@[^\/?#]+)$/.exec(args[0]) ||
-        /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
-    if (!match) {
-        return reply(`链接或表达式格式有误。
-示例:
-https://twitter.com/TomoyoKurosawa/status/1486136914864345092
-1486136914864345092
-last@TomoyoKurosawa
-last-1@sunflower930316,noreps=off,norts=on
-(表达式筛选参数详见 /help twitter_query)`);
-    }
-    if (Math.abs(Number(match[2])) >= 50) {
-        return reply('表达式中指定的回溯数量超出取值范围。');
-    }
-    let forceRefresh;
-    for (const arg of args.slice(1)) {
-        const optMatch = /^(force|refresh)=(.*)/.exec(arg);
-        if (!optMatch)
-            return reply(`未定义的查看参数:${arg}。`);
-        forceRefresh = { on: true, off: false }[optMatch[2]];
-    }
-    try {
-        twitter_1.sendTweet(match[1], chat, forceRefresh);
+        return reply('未输入自定义查询表达式。');
     }
-    catch (e) {
-        reply('推特机器人尚未加载完毕,请稍后重试。');
-    }
-}
-exports.view = view;
-function resendLast(chat, args, reply) {
-    view(chat, [(args[0] || '').replace(/^@?(.+)$/, 'last@$1'), 'refresh=on'], reply);
-}
-exports.resendLast = resendLast;
-function query(chat, args, reply) {
-    if (args.length === 0 || !args[0]) {
-        return reply('找不到要查询的用户。');
-    }
-    const match = /twitter.com\/([^\/?#]+)/.exec(args[0]) ||
-        /^([^\/?#]+)$/.exec(args[0]);
-    if (!match) {
-        return reply('链接或用户名格式有误。');
-    }
-    const conf = { username: match[1], noreps: 'on', norts: 'off' };
+    const conf = { query: args[0] };
     const confZH = {
         count: '数量上限',
         since: '起始点',
         until: '结束点',
-        noreps: '忽略回复推文(on/off)',
-        norts: '忽略原生转推(on/off)',
     };
     for (const arg of args.slice(1)) {
-        const optMatch = /^(count|since|until|noreps|norts)=(.*)/.exec(arg);
+        const optMatch = /^(count|since|until)=(.*)/.exec(arg);
         if (!optMatch)
             return reply(`未定义的查询参数:${arg}。`);
         const optKey = optMatch[1];
@@ -231,15 +45,19 @@ function query(chat, args, reply) {
         if (optMatch[2] === '')
             return reply(`查询${confZH[optKey]}参数值不可为空。`);
     }
-    if (conf.count !== undefined && !Number(conf.count) || Math.abs(Number(conf.count)) > 50) {
-        return reply('查询数量上限参数为零、非数值或超出取值范围。');
+    if (conf.count !== undefined && !Number(conf.count) || Number(conf.count) > 100) {
+        return reply('查询数量上限参数非正、非数值或超出取值范围。');
     }
     try {
-        twitter_1.sendTimeline(conf, chat);
+        (0, twitter_1.doSearch)(conf, chat);
     }
     catch (e) {
         logger.error(`error querying timeline, error: ${e}`);
         reply('推特机器人尚未加载完毕,请稍后重试。');
     }
 }
-exports.query = query;
+exports.search = search;
+function picSearch(chat, args, reply) {
+    search(chat, [args[0] && args[0] + '(filter:media)', ...args.slice(1)], reply);
+}
+exports.picSearch = picSearch;

+ 33 - 53
dist/koishi.js

@@ -15,7 +15,7 @@ require("koishi-adapter-onebot");
 const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const utils_1 = require("./utils");
-const logger = loggers_1.getLogger('qqbot');
+const logger = (0, loggers_1.getLogger)('qqbot');
 const cqUrlFix = (factory) => (...args) => factory(...args).replace(/(?<=\[CQ:.*)url=(?=(base64|file|https?):\/\/)/, 'file=');
 exports.Message = {
     Image: cqUrlFix(koishi_1.segment.image),
@@ -51,7 +51,7 @@ class default_1 {
             var _a, _b;
             let wasEmpty = false;
             const queue = (_a = this.messageQueues)[_b = `${type}:${id}`] || (_a[_b] = (() => { wasEmpty = true; return []; })());
-            queue.push(() => koishi_1.sleep(200).then(resolver));
+            queue.push(() => (0, koishi_1.sleep)(200).then(resolver));
             logger.debug(`no. of message currently queued for ${type}:${id}: ${queue.length}`);
             if (wasEmpty)
                 this.next(type, id);
@@ -139,7 +139,7 @@ class default_1 {
                             .then(() => { logger.info(`accepted friend request from ${userString} (from group ${groupString})`); })
                             .catch(error => { logger.error(`error accepting friend request from ${userString}, error: ${error}`); });
                     }
-                    utils_1.chainPromises(groupList.map(groupItem => (done) => Promise.resolve(done ||
+                    (0, utils_1.chainPromises)(groupList.map(groupItem => (done) => Promise.resolve(done ||
                         this.bot.getGroupMember(groupItem.groupId, session.userId).then(() => {
                             groupString = `${groupItem.groupName}(${groupItem.groupId})`;
                             return session.bot.handleFriendRequest(session.messageId, true)
@@ -170,71 +170,51 @@ class default_1 {
             }));
             this.app.middleware((session) => __awaiter(this, void 0, void 0, function* () {
                 const chat = yield this.getChat(session);
-                const cmdObj = command_1.parseCmd(session.content);
+                const cmdObj = (0, command_1.parseCmd)(session.content);
                 const reply = (msg) => __awaiter(this, void 0, void 0, function* () {
                     const userString = `${session.username}(${session.userId})`;
                     return (chat.chatType === "group" ? this.sendToGroup : this.sendToUser)(chat.chatID.toString(), msg)
                         .catch(error => { logger.error(`error replying to message from ${userString}, error: ${error}`); });
                 });
                 switch (cmdObj.cmd) {
-                    case 'twitter_view':
-                    case 'twitter_get':
-                        command_1.view(chat, cmdObj.args, reply);
+                    case 'twitter_search':
+                        (0, command_1.search)(chat, cmdObj.args, reply);
                         break;
-                    case 'twitter_resendlast':
-                        command_1.resendLast(chat, cmdObj.args, reply);
-                        break;
-                    case 'twitter_query':
-                    case 'twitter_gettimeline':
-                        command_1.query(chat, cmdObj.args, reply);
-                        break;
-                    case 'twitter_sub':
-                    case 'twitter_subscribe':
-                        this.botInfo.sub(chat, cmdObj.args, reply);
-                        break;
-                    case 'twitter_unsub':
-                    case 'twitter_unsubscribe':
-                        this.botInfo.unsub(chat, cmdObj.args, reply);
-                        break;
-                    case 'twitter_unsuball':
-                    case 'bye':
-                        this.botInfo.unsubAll(chat, cmdObj.args, reply);
-                        break;
-                    case 'ping':
-                    case 'twitter':
-                        this.botInfo.list(chat, cmdObj.args, reply);
+                    case 'twitterpic_search':
+                        (0, command_1.picSearch)(chat, cmdObj.args, reply);
                         break;
                     case 'help':
                         if (cmdObj.args.length === 0) {
-                            reply(`推特搬运机器人:
-/twitter - 查询当前聊天中的推文订阅
-/twitter_sub[scribe]〈链接|用户名〉- 订阅 Twitter 推文搬运
-/twitter_unsub[scribe]〈链接|用户名〉- 退订 Twitter 推文搬运
-/twitter_view〈链接|表达式〉[{force|refresh}={on|off}] - 查看推文(可选强制重新载入)
-/twitter_resendlast〈用户名〉- 强制重发该用户最后一条推文
-/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
-${chat.chatType === "temp" ?
-                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
+                            reply(`推特搜索机器人:
+/twitter_search〈表达式〉[参数列表...] - 进行自定义查询(详见 /help twitter_search)
+/twitterpic_search〈表达式〉[参数列表...] - 对媒体推文进行自定义查询`);
                         }
-                        else if (cmdObj.args[0] === 'twitter_query') {
+                        else if (cmdObj.args[0] === 'twitter_search') {
                             reply(`查询时间线中的推文:
-/twitter_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
-
+/twitter_search〈表达式〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
+/twitterpic_search 为 /twitter_search〈表达式〉(filter:media) \
+的简便写法
 参数列表(方框内全部为可选,留空则为默认):
-    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
+    count:查询数量上限(类型:正整数,最大值 100)[默认值:15]
     since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
-    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
-    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
-    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
+    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]`)
                                 .then(() => reply(`\
 起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
-推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
-count 为正时,从新向旧查询;为负时,从旧向新查询
-count 与 since/until 并用时,取二者中实际查询结果较少者
-例子:/twitter_query RiccaTachibana count=5 since="2019-12-30\
- UTC+9" until="2020-01-06 UTC+8" norts=on
-    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条推文,\
-其中不包含原生转推(实际上用户只发了 1 条)`));
+推荐的日期格式:2012-12-22 12:22 UTC+2(日期和时间均为可选,可分别添加)
+since 和 until 会被直接转换成 since_id 和 max_id 并覆盖表达式中同名参数,\
+但不会覆盖表达式中原生精确到日的 since 和 until。
+
+例子:/twitter_search #imas_cg_6th count=5 since="2018-12-02 \
+13:00 UTC+9" until="2018-12-08 17:00 UTC+8"
+     /twitter_search (from:mineda_mayu)(exclude:replies) \
+since="2020-12-24 00:00 UTC+8" until="2021-01-01 00:00 UTC+8"
+     /twitter_search "to:hirayama8emi OR from:choucho0115 \
+filter:native_video" until="2021-05-01 UTC+8"`))
+                                .then(() => reply(`\
+搜索表达式具体例子可以参考:
+     https://www.labnol.org/internet/twitter-search-tricks/13693/
+     https://developer.twitter.com/en/docs/twitter-api/v1/\
+rules-and-filtering/search-operators`));
                         }
                 }
             }), true);
@@ -246,7 +226,7 @@ count 与 since/until 并用时,取二者中实际查询结果较少者
             }
             catch (err) {
                 logger.error(`error connecting to bot provider at ${this.app.options.server}, will retry in 2.5s...`);
-                yield koishi_1.sleep(2500);
+                yield (0, koishi_1.sleep)(2500);
                 yield this.listen('retry connecting...');
             }
         });

+ 1 - 1
dist/loggers.js

@@ -4,7 +4,7 @@ exports.setLogLevels = exports.getLogger = void 0;
 const log4js_1 = require("log4js");
 const loggers = [];
 function getLogger(category) {
-    const l = log4js_1.getLogger(category);
+    const l = (0, log4js_1.getLogger)(category);
     l.level = 'info';
     loggers.push(l);
     return l;

+ 11 - 72
dist/main.js

@@ -1,26 +1,24 @@
 #!/usr/bin/env node
 "use strict";
-var _a;
 Object.defineProperty(exports, "__esModule", { value: true });
 const fs = require("fs");
 const path = require("path");
 const commandLineUsage = require("command-line-usage");
 const exampleConfig = require("../config.example.json");
-const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
 const twitter_1 = require("./twitter");
-const logger = loggers_1.getLogger();
+const logger = (0, loggers_1.getLogger)();
 const sections = [
     {
-        header: 'GoCQHTTP Twitter Bot',
-        content: 'The QQ Bot that forwards twitters.',
+        header: 'GoCQHTTP Twitter Search Bot',
+        content: 'The QQ Bot that search through tweets.',
     },
     {
         header: 'Synopsis',
         content: [
-            '$ twitter-bot {underline config.json}',
-            '$ twitter-bot {bold --help}',
+            '$ twitter-searchbot {underline config.json}',
+            '$ twitter-searchbot {bold --help}',
         ],
     },
     {
@@ -48,15 +46,13 @@ catch (e) {
     process.exit(1);
 }
 const requiredFields = [
-    'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
-    'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
+    'twitter_private_auth_token', 'twitter_private_csrf_token'
 ];
 const warningFields = [
     'cq_ws_host', 'cq_ws_port', 'cq_access_token',
-    ...((_a = config.redis) !== null && _a !== void 0 ? _a : exampleConfig.redis) ? ['redis_host', 'redis_port', 'redis_expire_time'] : [],
 ];
 const optionalFields = [
-    'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start', 'redis',
+    'loglevel'
 ].concat(warningFields);
 if (requiredFields.some((value) => config[value] === undefined)) {
     console.log(`${requiredFields.join(', ')} are required`);
@@ -69,73 +65,16 @@ optionalFields.forEach(key => {
         config[key] = exampleConfig[key];
     }
 });
-loggers_1.setLogLevels(config.loglevel);
-let lock;
-if (fs.existsSync(path.resolve(config.lockfile))) {
-    try {
-        lock = JSON.parse(fs.readFileSync(path.resolve(config.lockfile), 'utf8'));
-    }
-    catch (err) {
-        logger.error(`Failed to parse lockfile ${config.lockfile}: `, err);
-        lock = {
-            workon: 0,
-            feed: [],
-            threads: {},
-        };
-    }
-    fs.access(path.resolve(config.lockfile), fs.constants.W_OK, err => {
-        if (err) {
-            logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-            process.exit(1);
-        }
-    });
-}
-else {
-    lock = {
-        workon: 0,
-        feed: [],
-        threads: {},
-    };
-    try {
-        fs.writeFileSync(path.resolve(config.lockfile), JSON.stringify(lock));
-    }
-    catch (err) {
-        logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-        process.exit(1);
-    }
-}
-if (!config.resume_on_start) {
-    Object.keys(lock.threads).forEach(key => {
-        lock.threads[key].offset = '-1';
-    });
-}
+(0, loggers_1.setLogLevels)(config.loglevel);
 const qq = new koishi_1.default({
     access_token: config.cq_access_token,
     host: config.cq_ws_host,
     port: config.cq_ws_port,
     bot_id: config.cq_bot_qq,
-    list: (c, a, cb) => command_1.list(c, a, cb, lock),
-    sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
-    unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),
-    unsubAll: (c, a, cb) => command_1.unsubAll(c, a, cb, lock, config.lockfile),
 });
-const worker = new twitter_1.default({
-    consumerKey: config.twitter_consumer_key,
-    consumerSecret: config.twitter_consumer_secret,
-    accessTokenKey: config.twitter_access_token_key,
-    accessTokenSecret: config.twitter_access_token_secret,
-    lock,
-    lockfile: config.lockfile,
-    workInterval: config.work_interval,
+new twitter_1.default({
+    privateAuthToken: config.twitter_private_auth_token,
+    privateCsrfToken: config.twitter_private_csrf_token,
     bot: qq,
-    webshotDelay: config.webshot_delay,
-    mode: config.mode,
-    wsUrl: config.playwright_ws_spec_endpoint,
-    redis: !config.redis ? undefined : {
-        redisHost: config.redis_host,
-        redisPort: config.redis_port,
-        redisExpireTime: config.redis_expire_time,
-    },
 });
-worker.launch();
 qq.connect();

+ 0 - 47
dist/redis.js

@@ -1,47 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-const redis = require("redis");
-const loggers_1 = require("./loggers");
-const logger = loggers_1.getLogger('redis');
-class default_1 {
-    constructor(opt) {
-        this.chatAsString = (chat) => `${chat.chatType}:${chat.chatID.toString()}`;
-        this.cacheContent = (contentId, content) => new Promise((resolve, reject) => this.client.set(`content/${contentId}`, content, 'EX', 3600 * 24, (err, res) => err ? reject(err) : resolve(res))).then(res => {
-            logger.debug(`cached content ${contentId}, result: ${res}`);
-        }).catch((err) => {
-            logger.error(`failed to cache content ${contentId}, error: ${err}`);
-        });
-        this.cacheForChat = (postId, target) => {
-            const targetStr = this.chatAsString(target);
-            return new Promise((resolve, reject) => this.client.set(`sent/${targetStr}/${postId}`, 'true', 'EX', this.expireAfter, (err, res) => err ? reject(err) : resolve(res))).then(res => {
-                logger.debug(`cached post ${postId} for ${targetStr}, result: ${res}`);
-            }).catch((err) => {
-                logger.error(`failed to cache post ${postId} for ${targetStr}, error: ${err}`);
-            });
-        };
-        this.getContent = (contentId) => new Promise((resolve, reject) => this.client.get(`content/${contentId}`, (err, res) => err ? reject(err) : resolve(res))).then(res => {
-            logger.debug(`retrieved cached content ${contentId}, result: ${res}`);
-            return res;
-        }).catch((err) => {
-            logger.error(`failed to retrieve cached content ${contentId}, error: ${err}`);
-            throw err;
-        });
-        this.isCachedForChat = (postId, target) => {
-            const targetStr = this.chatAsString(target);
-            return new Promise((resolve, reject) => this.client.exists(`sent/${targetStr}/${postId}`, (err, res) => err ? reject(err) : resolve(res))).then(res => {
-                logger.debug(`retrieved status of post ${postId} for ${targetStr}, result: ${res}`);
-                return Boolean(res);
-            }).catch((err) => {
-                logger.error(`failed to retrieve status of post ${postId} for ${targetStr}, error: ${err}`);
-                return false;
-            });
-        };
-        this.client = redis.createClient({
-            host: opt.redisHost,
-            port: opt.redisPort,
-        });
-        this.expireAfter = opt.redisExpireTime;
-        logger.info(`loaded redis service at ${opt.redisHost}:${opt.redisPort}`);
-    }
-}
-exports.default = default_1;

+ 54 - 330
dist/twitter.js

@@ -1,365 +1,89 @@
 "use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
-    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
-    return new (P || (P = Promise))(function (resolve, reject) {
-        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
-        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
-        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
-        step((generator = generator.apply(thisArg, _arguments || [])).next());
-    });
-};
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.sendTimeline = exports.sendTweet = exports.ScreenNameNormalizer = void 0;
-const fs = require("fs");
-const path = require("path");
+exports.doSearch = void 0;
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
-const redis_1 = require("./redis");
 const utils_1 = require("./utils");
-const webshot_1 = require("./webshot");
-class ScreenNameNormalizer {
-    static normalizeLive(username) {
-        return __awaiter(this, void 0, void 0, function* () {
-            if (this._queryUser) {
-                return yield this._queryUser(username)
-                    .catch((err) => {
-                    if (err[0].code !== 50) {
-                        logger.warn(`error looking up user: ${err[0].message}`);
-                        return username;
-                    }
-                    return null;
-                });
-            }
-            return this.normalize(username);
-        });
-    }
-}
-exports.ScreenNameNormalizer = ScreenNameNormalizer;
-ScreenNameNormalizer.normalize = (username) => username.toLowerCase().replace(/^@/, '');
-let sendTweet = (id, receiver, forceRefresh) => {
+let doSearch = (conf, receiver) => {
     throw Error();
 };
-exports.sendTweet = sendTweet;
-let sendTimeline = (conf, receiver) => {
-    throw Error();
-};
-exports.sendTimeline = sendTimeline;
+exports.doSearch = doSearch;
 const TWITTER_EPOCH = 1288834974657;
 const snowflake = (epoch) => Number.isNaN(epoch) ? undefined :
     utils_1.BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
-const logger = loggers_1.getLogger('twitter');
-const maxTrials = 3;
-const retryInterval = 1500;
-const ordinal = (n) => {
-    switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
-        case 1:
-            return `${n}st`;
-        case 2:
-            return `${n}nd`;
-        case 3:
-            return `${n}rd`;
-        default:
-            return `${n}th`;
-    }
-};
-const retryOnError = (doWork, onRetry) => new Promise(resolve => {
-    const retry = (reason, count) => {
-        setTimeout(() => {
-            let terminate = false;
-            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
-            if (!terminate)
-                doWork().then(resolve).catch(error => retry(error, count + 1));
-        }, retryInterval);
-    };
-    doWork().then(resolve).catch(error => retry(error, 1));
-});
+const logger = (0, loggers_1.getLogger)('twitter');
+var AdaptiveSearch;
+(function (AdaptiveSearch) {
+    ;
+    AdaptiveSearch.isCursor = (entry) => entry.sortIndex === '0' || entry.sortIndex === '999999999';
+})(AdaptiveSearch || (AdaptiveSearch = {}));
 class default_1 {
     constructor(opt) {
-        this.launch = () => {
-            this.webshot = new webshot_1.default(this.wsUrl, this.mode, () => setTimeout(this.work, this.workInterval * 1000));
-        };
-        this.queryUser = (username) => this.client.get('users/show', { screen_name: username })
-            .then((user) => user.screen_name);
-        this.queryTimelineReverse = (conf) => {
-            if (!conf.since)
-                return this.queryTimeline(conf);
-            const count = conf.count;
-            const maxID = conf.until;
-            conf.count = undefined;
-            const until = () => utils_1.BigNumOps.min(maxID, utils_1.BigNumOps.plus(conf.since, String(7 * 24 * 3600 * 1000 * Math.pow(2, 22))));
-            conf.until = until();
-            const promise = (tweets) => this.queryTimeline(conf).then(newTweets => {
-                tweets = newTweets.concat(tweets);
-                conf.since = conf.until;
-                conf.until = until();
-                if (tweets.length >= count ||
-                    utils_1.BigNumOps.compare(conf.since, conf.until) >= 0) {
-                    return tweets.slice(-count);
-                }
-                return promise(tweets);
-            });
-            return promise([]);
-        };
-        this.queryTimeline = ({ username, count, since, until, noreps, norts }) => {
-            username = username.replace(/^@?(.*)$/, '@$1');
-            logger.info(`querying timeline of ${username} with config: ${JSON.stringify(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (count && { count })), (since && { since })), (until && { until })), (noreps && { noreps })), (norts && { norts })))}`);
-            const fetchTimeline = (config = {
-                screen_name: username.slice(1),
-                trim_user: true,
-                exclude_replies: noreps !== null && noreps !== void 0 ? noreps : true,
-                include_rts: !(norts !== null && norts !== void 0 ? norts : false),
-                since_id: since,
-                max_id: until,
-                tweet_mode: 'extended',
-            }, tweets = []) => this.client.get('statuses/user_timeline', config)
-                .then((newTweets) => {
+        this.search = (conf) => {
+            for (const key in conf) {
+                if (!conf[key])
+                    delete conf[key];
+            }
+            const origQuery = conf.query;
+            delete conf.query;
+            const paramNames = { until: 'max_id', since: 'since_id' };
+            logger.info(`performing custom query ${origQuery} with config: ${JSON.stringify(conf)}`);
+            const query = origQuery.replace(new RegExp(`(${Object.values(paramNames).join('|')}):\d+`, 'g'), ($0, $1) => conf[$1] ? '' : $0).replace(/\(\)/g, '') +
+                Object.keys(paramNames).map(k => conf[k] && `(${paramNames[k]}:${conf[k]})`).join();
+            const doSearch = (q, tweets = [], cursor) => this.privateClient.get('https://api.twitter.com/2/search/adaptive.json', Object.assign({ q, tweet_mode: 'extended', count: 20, include_entities: true, query_source: 'typed_query' }, (cursor && { cursor }))).then(({ globalObjects: { tweets: tweetDict, users: userDict }, timeline: { instructions: { 0: { addEntries: { entries } } } } }) => {
+                const newTweets = Object.values(tweetDict);
+                let bottomCursor;
                 if (newTweets.length) {
                     logger.debug(`fetched tweets: ${JSON.stringify(newTweets)}`);
-                    config.max_id = utils_1.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}`);
+                    entries.sort((e1, e2) => Number(e1.sortIndex) - Number(e2.sortIndex));
+                    if (AdaptiveSearch.isCursor(entries[0]))
+                        bottomCursor = entries[0];
+                    logger.info(`custom query ${origQuery} yielded ${newTweets.length} new tweets, next query will follow tweet ${entries[1].entryId.replace(/^sq-I-t-/, '')}`);
+                    newTweets.forEach(tweet => Object.assign(tweet, {
+                        user: userDict[tweet.user_id_str],
+                    }));
                     tweets.push(...newTweets);
                 }
-                if (!newTweets.length || tweets.length >= count) {
-                    logger.info(`timeline query of ${username} finished successfully, ${tweets.length} tweets have been fetched`);
-                    return tweets.slice(0, count);
+                if (!newTweets.length || tweets.length >= conf.count) {
+                    logger.info(`custom query ${origQuery} finished successfully, ${tweets.length} tweets have been fetched`);
+                    return tweets.slice(0, conf.count);
                 }
-                return fetchTimeline(config, tweets);
-            });
-            return fetchTimeline();
-        };
-        this.workOnTweets = (tweets, sendTweets, refresh = false) => Promise.all(tweets.map(tweet => ((this.redis && !refresh) ? this.redis.getContent(`webshot/${tweet.id_str}`) : Promise.reject())
-            .then(content => {
-            if (content === null)
-                throw Error();
-            logger.info(`retrieved cached webshot of tweet ${tweet.id_str} from redis database`);
-            const { msg, text, author } = JSON.parse(content);
-            sendTweets(tweet.retweeted_status ? tweet.retweeted_status.id_str : tweet.id_str, msg, text, author);
-        }).catch(() => this.webshot([tweet], (id, msg, text, author) => {
-            Promise.resolve()
-                .then(() => {
-                if (!this.redis)
-                    return;
-                logger.info(`caching webshot of tweet ${tweet.id_str} to redis database`);
-                this.redis.cacheContent(`webshot/${tweet.id_str}`, JSON.stringify({ msg, text, author }));
-            })
-                .then(() => sendTweets(id, msg, text, author));
-        }, this.webshotDelay))));
-        this.getTweet = (id, sender, refresh = false) => {
-            const endpoint = 'statuses/show';
-            const config = {
-                id,
-                tweet_mode: 'extended',
-            };
-            return this.client.get(endpoint, config)
-                .then((tweet) => {
-                logger.debug(`api returned tweet ${JSON.stringify(tweet)} for query id=${id}`);
-                return this.workOnTweets([tweet], sender, refresh);
+                return doSearch(query, tweets, bottomCursor === null || bottomCursor === void 0 ? void 0 : bottomCursor.content.operation.cursor.value);
             });
+            return doSearch(query);
         };
-        this.sendTweets = (config = { reportOnSkip: false, force: false }, ...to) => (id, msg, text, author) => {
-            to.forEach(subscriber => {
-                const { sourceInfo: source, reportOnSkip, force } = config;
-                const targetStr = JSON.stringify(subscriber);
-                const send = () => retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
-                    if (count <= maxTrials) {
-                        logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-                    }
-                    else {
-                        logger.warn(`${count - 1} consecutive failures while sending message chain, trying plain text instead...`);
-                        terminate(this.bot.sendTo(subscriber, author + text, true));
-                    }
-                }).then(() => {
-                    if (this.redis) {
-                        logger.info(`caching push status of this tweet (or its origin in case of a retweet) for ${targetStr}...`);
-                        return this.redis.cacheForChat(id, subscriber);
-                    }
-                });
-                ((this.redis && !force) ? this.redis.isCachedForChat(id, subscriber) : Promise.resolve(false))
-                    .then(isCached => {
-                    if (isCached) {
-                        logger.info(`skipped subscriber ${targetStr} as this tweet or the origin of this retweet has been sent already`);
-                        if (!reportOnSkip)
-                            return;
-                        text = `[最近发送过的推文:${id}]`;
-                        msg = author + text;
-                    }
-                    logger.info(`pushing data${source ? ` of ${source}` : ''} to ${targetStr}`);
-                    return send();
-                });
-            });
-        };
-        this.work = () => {
-            const lock = this.lock;
-            if (this.workInterval < 1)
-                this.workInterval = 1;
-            if (lock.feed.length === 0) {
-                setTimeout(() => {
-                    this.work();
-                }, this.workInterval * 1000);
-                return;
-            }
-            if (lock.workon >= lock.feed.length)
-                lock.workon = 0;
-            if (!lock.threads[lock.feed[lock.workon]] ||
-                !lock.threads[lock.feed[lock.workon]].subscribers ||
-                lock.threads[lock.feed[lock.workon]].subscribers.length === 0) {
-                logger.warn(`nobody subscribes thread ${lock.feed[lock.workon]}, removing from feed`);
-                delete lock.threads[lock.feed[lock.workon]];
-                lock.feed.splice(lock.workon, 1);
-                fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-                this.work();
-                return;
+        this.privateClient = new Twitter({
+            bearer_token: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
+            request: {
+                headers: {
+                    'Content-Type': 'application/x-www-form-urlencoded',
+                    Cookie: `auth_token=${opt.privateAuthToken}; ct0=${opt.privateCsrfToken};`,
+                    'X-CSRF-Token': opt.privateCsrfToken,
+                },
             }
-            const currentFeed = lock.feed[lock.workon];
-            logger.debug(`pulling feed ${currentFeed}`);
-            const promise = new Promise(resolve => {
-                let match = /https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/.exec(currentFeed);
-                let config;
-                let endpoint;
-                if (match) {
-                    if (match[1] === 'i') {
-                        config = {
-                            list_id: match[2],
-                            tweet_mode: 'extended',
-                        };
-                    }
-                    else {
-                        config = {
-                            owner_screen_name: match[1],
-                            slug: match[2],
-                            tweet_mode: 'extended',
-                        };
-                    }
-                    endpoint = 'lists/statuses';
-                }
-                else {
-                    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.threads[currentFeed].offset;
-                    if (offset > 0)
-                        config.since_id = offset;
-                    const getMore = (gotTweets = []) => this.client.get(endpoint, config, (error, 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.threads[currentFeed].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 || tweets.length <= 1)
-                            return resolve(gotTweets);
-                        config.max_id = tweets.slice(-1)[0].id_str;
-                        getMore(gotTweets.concat(tweets));
-                    });
-                    getMore();
-                }
-            });
-            promise.then((tweets) => {
-                logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
-                const currentThread = lock.threads[currentFeed];
-                const updateDate = () => currentThread.updatedAt = new Date().toString();
-                if (!tweets || tweets.length === 0) {
-                    updateDate();
-                    return;
-                }
-                const topOfFeed = tweets[0].id_str;
-                const updateOffset = () => currentThread.offset = topOfFeed;
-                if (currentThread.offset === '-1') {
-                    updateOffset();
-                    return;
-                }
-                if (currentThread.offset === '0')
-                    tweets.splice(1);
-                return this.workOnTweets(tweets, this.sendTweets({ sourceInfo: `thread ${currentFeed}` }, ...currentThread.subscribers))
-                    .then(updateDate).then(updateOffset);
-            })
-                .then(() => {
-                lock.workon++;
-                let timeout = this.workInterval * 1000 / lock.feed.length;
-                if (timeout < 1000)
-                    timeout = 1000;
-                fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-                setTimeout(() => {
-                    this.work();
-                }, timeout);
-            });
-        };
-        this.client = new Twitter({
-            consumer_key: opt.consumerKey,
-            consumer_secret: opt.consumerSecret,
-            access_token_key: opt.accessTokenKey,
-            access_token_secret: opt.accessTokenSecret,
         });
-        this.lockfile = opt.lockfile;
-        this.lock = opt.lock;
-        this.workInterval = opt.workInterval;
         this.bot = opt.bot;
-        this.webshotDelay = opt.webshotDelay;
-        this.mode = opt.mode;
-        this.wsUrl = opt.wsUrl;
-        if (opt.redis)
-            this.redis = new redis_1.default(opt.redis);
-        ScreenNameNormalizer._queryUser = this.queryUser;
-        exports.sendTweet = (idOrQuery, receiver, forceRefresh) => {
-            const send = (id) => this.getTweet(id, this.sendTweets({ sourceInfo: `tweet ${id}`, reportOnSkip: true, force: forceRefresh }, receiver), forceRefresh)
-                .catch((err) => {
-                var _a;
-                if (((_a = err[0]) === null || _a === void 0 ? void 0 : _a.code) === 34)
-                    return this.bot.sendTo(receiver, `找不到用户 ${match[2].replace(/^@?(.*)$/, '@$1')}。`);
-                if (err[0].code !== 144) {
-                    logger.warn(`error retrieving tweet: ${err[0].message}`);
-                    this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
-                }
-                this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
-            });
-            const match = /^last(|-\d+)@([^\/?#,]+)((?:,no.*?=[^,]*)*)$/.exec(idOrQuery);
-            const query = () => this.queryTimeline({
-                username: match[2],
-                count: 1 - Number(match[1]),
-                noreps: { on: true, off: false }[match[3].replace(/.*,noreps=([^,]*).*/, '$1')],
-                norts: { on: true, off: false }[match[3].replace(/.*,norts=([^,]*).*/, '$1')],
-            }).then(tweets => tweets.slice(-1)[0].id_str);
-            (match ? query() : Promise.resolve(idOrQuery)).then(send);
-        };
-        exports.sendTimeline = ({ username, count, since, until, noreps, norts }, receiver) => {
-            const countNum = Number(count) || 10;
-            (countNum > 0 ? this.queryTimeline : this.queryTimelineReverse)({
-                username,
+        exports.doSearch = ({ query, count, since, until }, receiver) => {
+            const countNum = Number(count) || 15;
+            this.search({
+                query,
                 count: Math.abs(countNum),
                 since: utils_1.BigNumOps.parse(since) || snowflake(new Date(since).getTime()),
                 until: utils_1.BigNumOps.parse(until) || snowflake(new Date(until).getTime()),
-                noreps: { on: true, off: false }[noreps],
-                norts: { on: true, off: false }[norts],
             })
-                .then(tweets => utils_1.chainPromises(tweets.map(tweet => () => this.bot.sendTo(receiver, `\
+                .then(tweets => (0, utils_1.chainPromises)(tweets.map(tweet => () => this.bot.sendTo(receiver, `\
+用户:${tweet.user.name} (@${tweet.user.screen_name})
 编号:${tweet.id_str}
 时间:${tweet.created_at}
 媒体:${tweet.extended_entities ? '有' : '无'}
 正文:\n${tweet.full_text.replace(/^([\s\S\n]{50})[\s\S\n]+?( https:\/\/t.co\/.*)?$/, '$1…$2')}`))
                 .concat(() => this.bot.sendTo(receiver, tweets.length ?
-                '时间线查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' :
-                '时间线查询完毕,没有找到符合条件的推文。'))))
+                '自定义查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' :
+                '自定义查询完毕,没有找到符合条件的推文。'))))
                 .catch((err) => {
-                var _a, _b, _c;
-                if (((_a = err[0]) === null || _a === void 0 ? void 0 : _a.code) !== 34) {
-                    logger.warn(`error retrieving timeline: ${((_b = err[0]) === null || _b === void 0 ? void 0 : _b.message) || err}`);
-                    return this.bot.sendTo(receiver, `获取时间线时出现错误:${((_c = err[0]) === null || _c === void 0 ? void 0 : _c.message) || err}`);
-                }
-                this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
+                var _a, _b;
+                logger.warn(`error retrieving timeline: ${((_a = err[0]) === null || _a === void 0 ? void 0 : _a.message) || err}`);
+                return this.bot.sendTo(receiver, `自定义查询时出现错误:${((_b = err[0]) === null || _b === void 0 ? void 0 : _b.message) || err}`);
             });
         };
     }

+ 0 - 397
dist/webshot.js

@@ -1,397 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-const fs_1 = require("fs");
-const util_1 = require("util");
-const axios_1 = require("axios");
-const CallableInstance = require("callable-instance");
-const html_entities_1 = require("html-entities");
-const pngjs_1 = require("pngjs");
-const puppeteer = require("playwright");
-const sharp = require("sharp");
-const temp = require("temp");
-const loggers_1 = require("./loggers");
-const koishi_1 = require("./koishi");
-const utils_1 = require("./utils");
-const xmlEntities = new html_entities_1.XmlEntities();
-const ZHType = (type) => new class extends String {
-    constructor() {
-        super(...arguments);
-        this.type = super.toString();
-        this.toString = () => `[${super.toString()}]`;
-    }
-}(type);
-const typeInZH = {
-    photo: ZHType('图片'),
-    video: ZHType('视频'),
-    animated_gif: ZHType('GIF'),
-};
-const logger = loggers_1.getLogger('webshot');
-class Webshot extends CallableInstance {
-    constructor(wsUrl, mode, onready) {
-        super('webshot');
-        this.connect = (onready) => axios_1.default.get(this.wsUrl)
-            .then(res => {
-            logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
-            const browserType = Object.keys(res.data)[0];
-            return puppeteer[browserType]
-                .connect({ wsEndpoint: res.data[browserType] });
-        })
-            .then(browser => this.browser = browser)
-            .then(() => {
-            logger.info('launched puppeteer browser');
-            if (onready)
-                return onready();
-        })
-            .catch(error => this.reconnect(error, onready));
-        this.reconnect = (error, onready) => {
-            logger.error(`connection error, reason: ${error}`);
-            logger.warn('trying to reconnect in 2.5s...');
-            return util_1.promisify(setTimeout)(2500)
-                .then(() => this.connect(onready));
-        };
-        this.extendEntity = (media) => {
-            logger.info('not working on a tweet');
-        };
-        this.truncateLongThread = (atId) => {
-            logger.info('not working on a tweet');
-        };
-        this.renderWebshot = (url, height, webshotDelay, ...morePostProcessings) => {
-            temp.track();
-            const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
-            const sharpToFile = (pic) => new Promise(resolve => {
-                const webshotTempFilePath = temp.path({ suffix: '.jpg' });
-                pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
-            });
-            const promise = new Promise((resolve, reject) => {
-                const width = 720;
-                const zoomFactor = 2;
-                logger.info(`shooting ${width}*${height} webshot for ${url}`);
-                this.browser.newPage({
-                    bypassCSP: true,
-                    deviceScaleFactor: zoomFactor,
-                    locale: 'ja-JP',
-                    timezoneId: 'Asia/Tokyo',
-                    userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
-                })
-                    .then(page => {
-                    const startTime = new Date().getTime();
-                    const getTimerTime = () => new Date().getTime() - startTime;
-                    const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
-                    page.setViewportSize({
-                        width: width / zoomFactor,
-                        height: height / zoomFactor,
-                    })
-                        .then(() => page.route('*:\/\/video.twimg.com\/**', route => { route.abort(); }))
-                        .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                        .then(() => Promise.race([
-                        page.waitForSelector('article', { state: 'attached', timeout: getTimeout() }),
-                        page.click('#placeholder+#ScriptLoadFailure input[value="Try again"]', { timeout: getTimeout() }),
-                        page.waitForSelector('div[role="button"]>div>span>:text-matches("^やりなおす|更新$")', { state: 'attached', timeout: getTimeout() })
-                            .then(() => page.reload({ timeout: getTimeout() })),
-                    ]))
-                        .then(() => page.addStyleTag({
-                        content: 'header,#layers{display:none!important}article{background-color:transparent!important}' +
-                            '[data-testid="caret"],[role="group"],[data-testid="tweet"] [class*=" "]+:last-child>*+[class*=" "]~div{display:none}',
-                    }))
-                        .then(() => page.addStyleTag({
-                        content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
-                    }))
-                        .then(() => page.evaluate(() => {
-                        const poll = setInterval(() => {
-                            document.querySelectorAll('div[data-testid="placementTracking"]').forEach(container => {
-                                if (container.querySelector('div[role="button"] svg')) {
-                                    container.innerHTML = container.innerHTML;
-                                    clearInterval(poll);
-                                }
-                            });
-                        }, 250);
-                    }))
-                        .then(() => page.waitForSelector('xpath=//section/*/*/div[.//article[not(.//time[not(ancestor::div[@aria-labelledby])])]]', { state: 'attached', timeout: getTimeout() }))
-                        .then(handle => handle.$$('xpath=..//a[contains(@href,"content_you_see")]/../../..//*[@role="button"]')
-                        .then(sensitiveToggles => {
-                        const count = sensitiveToggles.length;
-                        if (count)
-                            logger.info(`found ${count} sensitive ${count === 1 ? 'tweet' : 'tweets'} on page, uncollapsing...`);
-                        return utils_1.chainPromises(sensitiveToggles.filter(toggle => toggle.isVisible()).map(toggle => () => toggle.click()));
-                    })
-                        .then(() => handle))
-                        .then(handle => handle.$('[data-testid="tweet"]').then(owner => owner ? handle : null))
-                        .catch((err) => {
-                        if (err.name !== 'TimeoutError')
-                            throw err;
-                        logger.warn(`${err} (${getTimerTime()} ms)`);
-                        return page.evaluate(() => document.documentElement.outerHTML).then(html => {
-                            const path = temp.path({ suffix: '.html' });
-                            fs_1.writeFileSync(path, html);
-                            logger.warn(`saved debug html to ${path}`);
-                        }).then(() => page.screenshot()).then(screenshot => {
-                            sharpToFile(sharp(screenshot).jpeg({ quality: 90 })).then(fileUri => {
-                                logger.warn(`saved debug screenshot to ${fileUri.substring(7)}`);
-                            });
-                        }).then(() => null);
-                    })
-                        .then((handle) => {
-                        if (handle === null)
-                            throw new puppeteer.errors.TimeoutError();
-                        return handle.evaluate(div => {
-                            try {
-                                const selector = '[data-testid="tweet"] :nth-child(2)>:first-child a';
-                                const getProfileUrl = () => (div.querySelector(selector) || { href: '' }).href;
-                                const ownerProfileUrl = getProfileUrl();
-                                const bottom = div;
-                                while (div = div.previousElementSibling) {
-                                    if (getProfileUrl() !== ownerProfileUrl || div === bottom.previousElementSibling)
-                                        continue;
-                                    const top = document.documentElement.scrollTop = window.scrollY + div.getBoundingClientRect().top;
-                                    if (top > 10)
-                                        return div.querySelector('article a[aria-label]').href.replace(/.*\/status\//, '');
-                                }
-                            }
-                            catch (_a) { }
-                            document.documentElement.scrollTop = 0;
-                        }).then(this.truncateLongThread).then(() => handle);
-                    })
-                        .then(handle => handle.evaluate(div => {
-                        const cardImg = div.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
-                        if (typeof (cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src')) === 'string') {
-                            const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src'));
-                            if (match) {
-                                const [media_url_https, id_str] = match.slice(1);
-                                return {
-                                    media_url: media_url_https.replace(/^https/, 'http'),
-                                    media_url_https,
-                                    url: '',
-                                    display_url: '',
-                                    expanded_url: '',
-                                    type: 'photo',
-                                    id: Number(id_str),
-                                    id_str,
-                                    sizes: undefined,
-                                };
-                            }
-                        }
-                    }))
-                        .then(cardImg => {
-                        if (cardImg)
-                            this.extendEntity(cardImg);
-                    })
-                        .then(() => utils_1.chainPromises(morePostProcessings.map(func => () => func(page))))
-                        .then(() => util_1.promisify(setTimeout)(getTimeout()))
-                        .then(() => page.evaluate(() => document.activeElement.blur()))
-                        .then(() => page.screenshot())
-                        .then(screenshot => {
-                        new pngjs_1.PNG({
-                            filterType: 4,
-                            deflateLevel: 0,
-                        }).on('parsed', function () {
-                            const idx = (x, y) => (this.width * y + x) << 2;
-                            let boundary = null;
-                            const x = zoomFactor * 2;
-                            for (let y = x; y < this.height; y += zoomFactor) {
-                                if (this.data[idx(x, y)] !== this.data[idx(x, y - zoomFactor)] &&
-                                    this.data[idx(x, y)] === this.data[idx(x + zoomFactor * 10, y)]) {
-                                    boundary = y;
-                                    break;
-                                }
-                            }
-                            if (boundary !== null) {
-                                logger.info(`found boundary at ${boundary}, cropping image`);
-                                this.data = this.data.slice(0, idx(this.width, boundary));
-                                this.height = boundary;
-                                sharpToFile(jpeg(this.pack())).then(path => {
-                                    logger.info(`finished webshot for ${url}`);
-                                    resolve({ path, boundary });
-                                });
-                            }
-                            else if (height >= 8 * 1920) {
-                                logger.warn('too large, consider as a bug, returning');
-                                sharpToFile(jpeg(this.pack())).then(path => {
-                                    resolve({ path, boundary: 0 });
-                                });
-                            }
-                            else {
-                                logger.info('unable to find boundary, try shooting a larger image');
-                                resolve({ path: '', boundary });
-                            }
-                        }).parse(screenshot);
-                    })
-                        .catch(err => {
-                        if (err instanceof Error && err.name !== 'TimeoutError')
-                            throw err;
-                        logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-                        resolve({ path: '', boundary: 0 });
-                    })
-                        .finally(() => { page.close(); });
-                })
-                    .catch(reject);
-            });
-            return promise.then(data => {
-                if (data.boundary === null) {
-                    return this.renderWebshot(url, height + 1920, webshotDelay, ...morePostProcessings);
-                }
-                else
-                    return data.path;
-            }).catch(error => this.reconnect(error)
-                .then(() => this.renderWebshot(url, height, webshotDelay, ...morePostProcessings)));
-        };
-        this.fetchMedia = (url) => new Promise((resolve, reject) => {
-            logger.info(`fetching ${url}`);
-            axios_1.default({
-                method: 'get',
-                url,
-                responseType: 'arraybuffer',
-                timeout: 150000,
-            }).then(res => {
-                if (res.status === 200) {
-                    logger.info(`successfully fetched ${url}`);
-                    resolve(res.data);
-                }
-                else {
-                    logger.error(`failed to fetch ${url}: ${res.status}`);
-                    reject();
-                }
-            }).catch(err => {
-                logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
-                reject();
-            });
-        }).then(data => {
-            var _a;
-            return (ext => {
-                const mediaTempFilePath = temp.path({ suffix: `.${ext}` });
-                fs_1.writeFileSync(mediaTempFilePath, Buffer.from(data));
-                const path = `file://${mediaTempFilePath}`;
-                switch (ext) {
-                    case 'jpg':
-                    case 'png':
-                        return koishi_1.Message.Image(path);
-                    case 'mp4':
-                        return koishi_1.Message.Video(path);
-                }
-                logger.warn('unable to find MIME type of fetched media, failing this fetch');
-                throw Error();
-            })(((_a = (/\?format=([a-z]+)&/.exec(url))) !== null && _a !== void 0 ? _a : (/.*\/.*\.([^?]+)/.exec(url)))[1]);
-        });
-        if (this.mode = mode) {
-            onready();
-        }
-        else {
-            this.wsUrl = wsUrl;
-            this.connect(onready);
-        }
-    }
-    webshot(tweets, callback, webshotDelay) {
-        let promise = new Promise(resolve => {
-            resolve();
-        });
-        tweets.forEach(twi => {
-            promise = promise.then(() => {
-                logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
-            });
-            const originTwi = twi.retweeted_status || twi;
-            let messageChain = '';
-            let truncatedAt;
-            let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
-            if (twi.retweeted_status)
-                author += `RT @${twi.retweeted_status.user.screen_name}: `;
-            let text = originTwi.full_text;
-            promise = promise.then(() => {
-                if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
-                    originTwi.entities.urls.forEach(url => {
-                        text = text.replace(new RegExp(url.url, 'gm'), url.expanded_url);
-                    });
-                }
-                if (originTwi.extended_entities) {
-                    originTwi.extended_entities.media.forEach(media => {
-                        text = text.replace(new RegExp(media.url, 'gm'), this.mode === 1 ? typeInZH[media.type] : '');
-                    });
-                }
-                if (this.mode > 0)
-                    messageChain += (author + xmlEntities.decode(text));
-            });
-            if (this.mode === 0) {
-                const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
-                this.extendEntity = (cardImg) => {
-                    var _a, _b;
-                    originTwi.extended_entities = Object.assign(Object.assign({}, originTwi.extended_entities), { media: [
-                            ...(_b = (_a = originTwi.extended_entities) === null || _a === void 0 ? void 0 : _a.media) !== null && _b !== void 0 ? _b : [],
-                            cardImg,
-                        ] });
-                };
-                this.truncateLongThread = (atId) => {
-                    if (!atId)
-                        return;
-                    logger.info(`thread too long, truncating at tweet ${atId}...`);
-                    truncatedAt = atId;
-                };
-                promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-                    .then(fileurl => {
-                    if (fileurl)
-                        return koishi_1.Message.Image(fileurl);
-                    return '[截图不可用] ' + author + text;
-                })
-                    .then(msg => {
-                    if (msg)
-                        messageChain += msg;
-                });
-            }
-            if (1 - this.mode % 2)
-                promise = promise.then(() => {
-                    if (originTwi.extended_entities) {
-                        return utils_1.chainPromises(originTwi.extended_entities.media.map(media => () => {
-                            let url;
-                            if (media.type === 'photo') {
-                                url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';
-                            }
-                            else {
-                                url = media.video_info.variants
-                                    .filter(variant => variant.bitrate !== undefined)
-                                    .sort((var1, var2) => var2.bitrate - var1.bitrate)
-                                    .map(variant => variant.url)[0];
-                            }
-                            const altMessage = `\n[失败的${typeInZH[media.type].type}:${url}]`;
-                            return this.fetchMedia(url)
-                                .catch(error => {
-                                logger.warn('unable to fetch media, sending plain text instead...');
-                                return altMessage;
-                            })
-                                .then(msg => { messageChain += msg; });
-                        }));
-                    }
-                });
-            if (this.mode === 0) {
-                if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
-                    promise = promise.then(() => {
-                        const urls = originTwi.entities.urls
-                            .filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
-                            .map(urlObj => `\n\ud83d\udd17 ${urlObj.expanded_url}`);
-                        if (urls.length) {
-                            messageChain += urls.join('');
-                        }
-                    });
-                }
-            }
-            promise = promise.then(() => {
-                if (truncatedAt) {
-                    messageChain += `\n回复此命令查看对话串中更早的推文:\n/twitter_view ${truncatedAt}`;
-                }
-            });
-            if (originTwi.is_quote_status) {
-                promise = promise.then(() => {
-                    var _a, _b;
-                    const match = /\/status\/(\d+)/.exec((_a = originTwi.quoted_status_permalink) === null || _a === void 0 ? void 0 : _a.expanded);
-                    const blockQuoteIdStr = match ? match[1] : (_b = originTwi.quoted_status) === null || _b === void 0 ? void 0 : _b.id_str;
-                    if (blockQuoteIdStr)
-                        messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
-                });
-            }
-            promise.then(() => {
-                logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-                logger.info(JSON.stringify(koishi_1.Message.ellipseBase64(messageChain)));
-                const twiId = twi.retweeted_status ? twi.retweeted_status.id_str : twi.id_str;
-                callback(twiId, messageChain, xmlEntities.decode(text), author);
-            });
-        });
-        return promise;
-    }
-}
-exports.default = Webshot;

File diff suppressed because it is too large
+ 0 - 6
dist/webshot_test.js


+ 5 - 22
package.json

@@ -1,18 +1,15 @@
 {
-  "name": "@CL-Jeremy/mirai-twitter-bot",
-  "version": "0.4.0",
-  "description": "Mirai Twitter Bot",
+  "name": "gocqhttp-twitter-searchbot",
+  "version": "0.0.1",
+  "description": "GoCQHTTP Twitter Search Bot",
   "main": "./dist/main.js",
   "bin": {
-    "mirai-twitter-bot": "./dist/main.js"
+    "twitter-searchbot": "./dist/main.js"
   },
   "repository": {
     "type": "git",
     "url": "git+https://github.com/CL-Jeremy/mirai-twitter-bot.git"
   },
-  "publishConfig": {
-    "registry": "https://npm.pkg.github.com/"
-  },
   "keywords": [
     "qq",
     "qqbot",
@@ -30,30 +27,16 @@
     "lint": "npx eslint --fix --ext .ts ./"
   },
   "dependencies": {
-    "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
-    "html-entities": "^1.3.1",
     "koishi": "^3.10.0",
     "koishi-adapter-onebot": "^3.0.8",
     "log4js": "^6.3.0",
-    "playwright": "^1.9.1",
-    "pngjs": "^5.0.0",
-    "read-all-stream": "^3.1.0",
-    "redis": "^3.1.2",
-    "sha1": "^1.1.1",
-    "sharp": "^0.25.4",
-    "temp": "^0.9.1",
     "twitter": "^1.7.1",
-    "typescript": "^4.2.3"
+    "typescript": "^4.5.5"
   },
   "devDependencies": {
     "@types/command-line-usage": "^5.0.1",
     "@types/node": "^14.14.22",
-    "@types/pngjs": "^3.4.2",
-    "@types/puppeteer": "^1.5.0",
-    "@types/redis": "^2.8.6",
-    "@types/sharp": "^0.25.0",
-    "@types/temp": "^0.8.34",
     "@types/twitter": "^1.7.0",
     "@typescript-eslint/eslint-plugin": "^4.22.0",
     "@typescript-eslint/parser": "^4.22.0",

+ 13 - 212
src/command.ts

@@ -1,13 +1,5 @@
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/member-delimiter-style */
-/* eslint-disable prefer-arrow/prefer-arrow-functions */
-import * as fs from 'fs';
-import * as path from 'path';
-
-import { relativeDate } from './datetime';
 import { getLogger } from './loggers';
-import { sendTimeline, sendTweet, ScreenNameNormalizer as normalizer } from './twitter';
-import { BigNumOps } from './utils';
+import { doSearch } from './twitter';
 
 const logger = getLogger('command');
 
@@ -34,227 +26,36 @@ function parseCmd(message: string): {
   };
 }
 
-function parseLink(link: string): string[] {
-  let match =
-    /twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/.exec(link) ||
-    /^([^\/?#]+)\/([^\/?#]+)$/.exec(link);
-  if (match) return [match[1], `/lists/${match[2]}`];
-  match =
-    /twitter.com\/([^\/?#]+)\/status\/(\d+)/.exec(link);
-  if (match) return [match[1], `/status/${match[2]}`];
-  match =
-    /twitter.com\/([^\/?#]+)/.exec(link) ||
-    /^([^\/?#]+)$/.exec(link);
-  if (match) return [match[1]];
-  return;
-}
-
-function linkBuilder(userName: string, more = ''): string {
-  if (!userName) return;
-  return `https://twitter.com/${userName}${more}`;
-}
-
-function linkFinder(checkedMatch: string[], chat: IChat, lock: ILock): [string, number] {
-  const normalizedLink =
-    linkBuilder(normalizer.normalize(checkedMatch[0]), checkedMatch[1]?.toLowerCase());
-  const link = Object.keys(lock.threads).find(realLink =>
-    normalizedLink === realLink.replace(/\/@/, '/').toLowerCase()
-  );
-  if (!link) return [null, -1];
-  const index = lock.threads[link].subscribers.findIndex(({chatID, chatType}) =>
-    chat.chatID === chatID && chat.chatType === chatType
-  );
-  return [link, index];
-}
-
-function sub(chat: IChat, args: string[], reply: (msg: string) => any,
-  lock: ILock, lockfile: string
-): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  if (args.length === 0) {
-    return reply('找不到要订阅的链接。');
-  }
-  const match = parseLink(args[0]);
-  if (!match) {
-    return reply(`订阅链接格式错误:
-示例:
-https://twitter.com/Saito_Shuka
-https://twitter.com/rikakomoe/lists/lovelive
-https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
-  }
-  let offset = '0';
-  if (match[1]) {
-    const matchStatus = /\/status\/(\d+)/.exec(match[1]);
-    if (matchStatus) {
-      offset = BigNumOps.plus(matchStatus[1], '-1');
-      delete match[1];
-    }
-  }
-  const subscribeTo = (link: string, config: {addNew?: boolean, msg?: string} = {}) => {
-    const {addNew = false, msg = `已为此聊天订阅 ${link}`} = config;
-    if (addNew) {
-      lock.feed.push(link);
-      lock.threads[link] = {
-        offset,
-        subscribers: [],
-        updatedAt: '',
-      };
-    }
-    lock.threads[link].subscribers.push(chat);
-    logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
-    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-    reply(msg);
-  };
-  const [realLink, index] = linkFinder(match, chat, lock);
-  if (index > -1) return reply('此聊天已订阅此链接。');
-  if (realLink) return subscribeTo(realLink);
-  const [rawUserName, more] = match;
-  if (rawUserName.toLowerCase() === 'i' && /lists\/(\d+)/.exec(more)) {
-    return subscribeTo(linkBuilder('i', more), {addNew: true});
-  }
-  normalizer.normalizeLive(rawUserName).then(userName => {
-    if (!userName) return reply(`找不到用户 ${rawUserName.replace(/^@?(.*)$/, '@$1')}。`);
-    const link = linkBuilder(userName, more);
-    const msg = (offset === '0') ?
-      undefined :
-      `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
-(参见:https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html)`;
-    subscribeTo(link, {addNew: true, msg});
-  });
-}
-
-function unsubAll(chat: IChat, args: string[], reply: (msg: string) => any,
-  lock: ILock, lockfile: string
-): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  Object.entries(lock.threads).forEach(([link, {subscribers}]) => {
-    const index = subscribers.indexOf(chat);
-    if (index === -1) return;
-    subscribers.splice(index, 1);
-    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-    logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-  });
-  return reply(`已为此聊天退订所有推特链接。`);
-}
-
-function unsub(chat: IChat, args: string[], reply: (msg: string) => any,
-  lock: ILock, lockfile: string
-): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  if (args.length === 0) {
-    return reply('找不到要退订的链接。');
-  }
-  const match = parseLink(args[0]);
-  if (!match) {
-    return reply('链接格式有误。');
-  }
-  const [link, index] = linkFinder(match, chat, lock);
-  if (index === -1) return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
-  else {
-    lock.threads[link].subscribers.splice(index, 1);
-    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-    logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-    return reply(`已为此聊天退订 ${link}`);
-  }
-}
-
-function list(chat: IChat, _: string[], reply: (msg: string) => any, lock: ILock): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  const links = [];
-  Object.keys(lock.threads).forEach(key => {
-    if (lock.threads[key].subscribers.find(({chatID, chatType}) => 
-      chat.chatID === chatID && chat.chatType === chatType
-    )) links.push(`${key} ${relativeDate(lock.threads[key].updatedAt)}`);
-  });
-  return reply('此聊天中订阅的链接:\n' + links.join('\n'));
-}
-
-function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
+function search(chat: IChat, args: string[], reply: (msg: string) => any): void {
   if (args.length === 0 || !args[0]) {
-    return reply('找不到要查看的链接或表达式。');
-  }
-  const match =
-    /^(last(?:|-(\d+))@[^\/?#]+)$/.exec(args[0]) ||
-    /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
-  if (!match) {
-    return reply(`链接或表达式格式有误。
-示例:
-https://twitter.com/TomoyoKurosawa/status/1486136914864345092
-1486136914864345092
-last@TomoyoKurosawa
-last-1@sunflower930316,noreps=off,norts=on
-(表达式筛选参数详见 /help twitter_query)`);
-  }
-  if (Math.abs(Number(match[2])) >= 50) {
-    return reply('表达式中指定的回溯数量超出取值范围。');
+    return reply('未输入自定义查询表达式。');
   }
-  let forceRefresh: boolean;
-  for (const arg of args.slice(1)) {
-    const optMatch = /^(force|refresh)=(.*)/.exec(arg);
-    if (!optMatch) return reply(`未定义的查看参数:${arg}。`);
-    forceRefresh = {on: true, off: false}[optMatch[2]];
-  }
-  try {
-    sendTweet(match[1], chat, forceRefresh);
-  } catch (e) {
-    reply('推特机器人尚未加载完毕,请稍后重试。');
-  }
-}
-
-function resendLast(chat: IChat, args: string[], reply: (msg: string) => any): void {
-  view(chat, [(args[0] || '').replace(/^@?(.+)$/, 'last@$1'), 'refresh=on'], reply);
-}
-
-function query(chat: IChat, args: string[], reply: (msg: string) => any): void {
-  if (args.length === 0 || !args[0]) {
-    return reply('找不到要查询的用户。');
-  }
-  const match = 
-    /twitter.com\/([^\/?#]+)/.exec(args[0]) ||
-    /^([^\/?#]+)$/.exec(args[0]);
-  if (!match) {
-    return reply('链接或用户名格式有误。');
-  }
-  const conf: {
-    username: string,
-    count?: string,
-    since?: string,
-    until?: string,
-    noreps: string,
-    norts: string,
-  } = {username: match[1], noreps: 'on', norts: 'off'};
-  const confZH: Record<Exclude<keyof typeof conf, 'username'>, string> = {
+  const conf: Parameters<typeof doSearch>[0] = {query: args[0]};
+  const confZH: Record<Exclude<keyof typeof conf, 'query'>, string> = {
     count: '数量上限',
     since: '起始点',
     until: '结束点',
-    noreps: '忽略回复推文(on/off)',
-    norts: '忽略原生转推(on/off)',
   };
   for (const arg of args.slice(1)) {
-    const optMatch = /^(count|since|until|noreps|norts)=(.*)/.exec(arg);
+    const optMatch = /^(count|since|until)=(.*)/.exec(arg);
     if (!optMatch) return reply(`未定义的查询参数:${arg}。`);
     const optKey = optMatch[1] as keyof typeof confZH;
     if (optMatch.length === 1) return reply(`查询${confZH[optKey]}参数格式有误。`);
     conf[optKey] = optMatch[2];
     if (optMatch[2] === '') return reply(`查询${confZH[optKey]}参数值不可为空。`);
   }
-  if (conf.count !== undefined && !Number(conf.count) || Math.abs(Number(conf.count)) > 50) {
-    return reply('查询数量上限参数为零、非数值或超出取值范围。');
+  if (conf.count !== undefined && !Number(conf.count) || Number(conf.count) > 100) {
+    return reply('查询数量上限参数非正、非数值或超出取值范围。');
   }
   try {
-    sendTimeline(conf, chat);
+    doSearch(conf, chat);
   } catch (e) {
     logger.error(`error querying timeline, error: ${e}`);
     reply('推特机器人尚未加载完毕,请稍后重试。');
   }
 }
 
-export { parseCmd, sub, list, unsub, unsubAll, view, resendLast, query };
+function picSearch(chat: IChat, args: string[], reply: (msg: string) => any): void {
+  search(chat, [args[0] && args[0] + '(filter:media)', ...args.slice(1)], reply);
+}
+export { parseCmd, search, picSearch };

+ 29 - 55
src/koishi.ts

@@ -2,7 +2,7 @@ import { App, Bot, segment, Session, sleep } from 'koishi';
 import 'koishi-adapter-onebot';
 import { Message as CQMessage, SenderInfo } from 'koishi-adapter-onebot';
 
-import { parseCmd, query, view, resendLast } from './command';
+import { parseCmd, search, picSearch } from './command';
 import { getLogger } from './loggers';
 import { chainPromises } from './utils';
 
@@ -15,10 +15,6 @@ interface IQQProps {
   host: string;
   port: number;
   bot_id: number;
-  list(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  sub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  unsub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  unsubAll(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
 }
 
 const cqUrlFix = (factory: segment.Factory<string | ArrayBuffer | Buffer>) =>
@@ -197,65 +193,43 @@ export default class {
           .catch(error => { logger.error(`error replying to message from ${userString}, error: ${error}`); });
       };
       switch (cmdObj.cmd) {
-        case 'twitter_view':
-        case 'twitter_get':
-          view(chat, cmdObj.args, reply);
+        case 'twitter_search':
+          search(chat, cmdObj.args, reply);
           break;
-        case 'twitter_resendlast':
-          resendLast(chat, cmdObj.args, reply);
-          break;
-        case 'twitter_query':
-        case 'twitter_gettimeline':
-          query(chat, cmdObj.args, reply);
-          break;
-        case 'twitter_sub':
-        case 'twitter_subscribe':
-          this.botInfo.sub(chat, cmdObj.args, reply);
-          break;
-        case 'twitter_unsub':
-        case 'twitter_unsubscribe':
-          this.botInfo.unsub(chat, cmdObj.args, reply);
-          break;
-        case 'twitter_unsuball':
-        case 'bye':
-          this.botInfo.unsubAll(chat, cmdObj.args, reply);
-          break;
-        case 'ping':
-        case 'twitter':
-          this.botInfo.list(chat, cmdObj.args, reply);
+        case 'twitterpic_search':
+          picSearch(chat, cmdObj.args, reply);
           break;
         case 'help':
           if (cmdObj.args.length === 0) {
-            reply(`推特搬运机器人:
-/twitter - 查询当前聊天中的推文订阅
-/twitter_sub[scribe]〈链接|用户名〉- 订阅 Twitter 推文搬运
-/twitter_unsub[scribe]〈链接|用户名〉- 退订 Twitter 推文搬运
-/twitter_view〈链接|表达式〉[{force|refresh}={on|off}] - 查看推文(可选强制重新载入)
-/twitter_resendlast〈用户名〉- 强制重发该用户最后一条推文
-/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
-${chat.chatType === ChatType.Temp ?
-    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
-}`);
-          } else if (cmdObj.args[0] === 'twitter_query') {
+            reply(`推特搜索机器人:
+/twitter_search〈表达式〉[参数列表...] - 进行自定义查询(详见 /help twitter_search)
+/twitterpic_search〈表达式〉[参数列表...] - 对媒体推文进行自定义查询`);
+          } else if (cmdObj.args[0] === 'twitter_search') {
             reply(`查询时间线中的推文:
-/twitter_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
-
+/twitter_search〈表达式〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
+/twitterpic_search 为 /twitter_search〈表达式〉(filter:media) \
+的简便写法
 参数列表(方框内全部为可选,留空则为默认):
-    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
+    count:查询数量上限(类型:正整数,最大值 100)[默认值:15]
     since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
-    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
-    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
-    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
+    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]`)
               .then(() => reply(`\
 起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
-推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
-count 为正时,从新向旧查询;为负时,从旧向新查询
-count 与 since/until 并用时,取二者中实际查询结果较少者
-例子:/twitter_query RiccaTachibana count=5 since="2019-12-30\
- UTC+9" until="2020-01-06 UTC+8" norts=on
-    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条推文,\
-其中不包含原生转推(实际上用户只发了 1 条)`)
-              );
+推荐的日期格式:2012-12-22 12:22 UTC+2(日期和时间均为可选,可分别添加)
+since 和 until 会被直接转换成 since_id 和 max_id 并覆盖表达式中同名参数,\
+但不会覆盖表达式中原生精确到日的 since 和 until。
+
+例子:/twitter_search #imas_cg_6th count=5 since="2018-12-02 \
+13:00 UTC+9" until="2018-12-08 17:00 UTC+8"
+     /twitter_search (from:mineda_mayu)(exclude:replies) \
+since="2020-12-24 00:00 UTC+8" until="2021-01-01 00:00 UTC+8"
+     /twitter_search "to:hirayama8emi OR from:choucho0115 \
+filter:native_video" until="2021-05-01 UTC+8"`))
+              .then(() => reply(`\
+搜索表达式具体例子可以参考:
+     https://www.labnol.org/internet/twitter-search-tricks/13693/
+     https://developer.twitter.com/en/docs/twitter-api/v1/\
+rules-and-filtering/search-operators`));
           }
       }
     }, true);

+ 9 - 68
src/main.ts

@@ -6,7 +6,6 @@ import * as path from 'path';
 import * as commandLineUsage from 'command-line-usage';
 
 import * as exampleConfig from '../config.example.json';
-import { list, sub, unsub, unsubAll } from './command';
 import { getLogger, setLogLevels } from './loggers';
 import QQBot from './koishi';
 import Worker from './twitter';
@@ -15,14 +14,14 @@ const logger = getLogger();
 
 const sections: commandLineUsage.Section[] = [
   {
-    header: 'GoCQHTTP Twitter Bot',
-    content: 'The QQ Bot that forwards twitters.',
+    header: 'GoCQHTTP Twitter Search Bot',
+    content: 'The QQ Bot that search through tweets.',
   },
   {
     header: 'Synopsis',
     content: [
-      '$ twitter-bot {underline config.json}',
-      '$ twitter-bot {bold --help}',
+      '$ twitter-searchbot {underline config.json}',
+      '$ twitter-searchbot {bold --help}',
     ],
   },
   {
@@ -56,17 +55,15 @@ try {
 }
 
 const requiredFields = [
-  'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
-  'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
+  'twitter_private_auth_token', 'twitter_private_csrf_token'
 ];
 
 const warningFields = [
   'cq_ws_host', 'cq_ws_port', 'cq_access_token',
-  ...(config.redis ?? exampleConfig.redis) ? ['redis_host', 'redis_port', 'redis_expire_time'] : [],
 ];
 
 const optionalFields = [
-  'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start', 'redis',
+  'loglevel'
 ].concat(warningFields);
 
 if (requiredFields.some((value) => config[value] === undefined)) {
@@ -83,73 +80,17 @@ optionalFields.forEach(key => {
 
 setLogLevels(config.loglevel);
 
-let lock: ILock;
-if (fs.existsSync(path.resolve(config.lockfile))) {
-  try {
-    lock = JSON.parse(fs.readFileSync(path.resolve(config.lockfile), 'utf8')) as ILock;
-  } catch (err) {
-    logger.error(`Failed to parse lockfile ${config.lockfile}: `, err);
-    lock = {
-      workon: 0,
-      feed: [],
-      threads: {},
-    };
-  }
-  fs.access(path.resolve(config.lockfile), fs.constants.W_OK, err => {
-    if (err) {
-      logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-      process.exit(1);
-    }
-  });
-} else {
-  lock = {
-    workon: 0,
-    feed: [],
-    threads: {},
-  };
-  try {
-    fs.writeFileSync(path.resolve(config.lockfile), JSON.stringify(lock));
-  } catch (err) {
-    logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-    process.exit(1);
-  }
-}
-
-if (!config.resume_on_start) {
-  Object.keys(lock.threads).forEach(key => {
-    lock.threads[key].offset = '-1';
-  });
-}
-
 const qq = new QQBot({
   access_token: config.cq_access_token,
   host: config.cq_ws_host,
   port: config.cq_ws_port,
   bot_id: config.cq_bot_qq,
-  list: (c, a, cb) => list(c, a, cb, lock),
-  sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
-  unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),
-  unsubAll: (c, a, cb) => unsubAll(c, a, cb, lock, config.lockfile),
 });
 
-const worker = new Worker({
-  consumerKey: config.twitter_consumer_key,
-  consumerSecret: config.twitter_consumer_secret,
-  accessTokenKey: config.twitter_access_token_key,
-  accessTokenSecret: config.twitter_access_token_secret,
-  lock,
-  lockfile: config.lockfile,
-  workInterval: config.work_interval,
+new Worker({
+  privateAuthToken: config.twitter_private_auth_token,
+  privateCsrfToken: config.twitter_private_csrf_token,
   bot: qq,
-  webshotDelay: config.webshot_delay,
-  mode: config.mode,
-  wsUrl: config.playwright_ws_spec_endpoint,
-  redis: !config.redis ? undefined : {
-    redisHost: config.redis_host,
-    redisPort: config.redis_port,
-    redisExpireTime: config.redis_expire_time,
-  },
 });
-worker.launch();
 
 qq.connect();

+ 0 - 69
src/redis.ts

@@ -1,69 +0,0 @@
-import * as redis from 'redis';
-import { getLogger } from './loggers';
-
-const logger = getLogger('redis');
-
-export default class {
-
-  private client: redis.RedisClient;
-  private expireAfter: number;
-
-  constructor(opt: IRedisConfig) {
-    this.client = redis.createClient({
-      host: opt.redisHost,
-      port: opt.redisPort,
-    });
-    this.expireAfter = opt.redisExpireTime;
-    logger.info(`loaded redis service at ${opt.redisHost}:${opt.redisPort}`);
-  }
-
-  private chatAsString = (chat: IChat) => `${chat.chatType}:${chat.chatID.toString()}`;
-
-  public cacheContent = (contentId: string, content: string) =>
-    new Promise<'OK'>((resolve, reject) =>
-      this.client.set(`content/${contentId}`, content, 'EX', 3600 * 24, (err, res) =>
-        err ? reject(err) : resolve(res)
-      )
-    ).then(res => {
-      logger.debug(`cached content ${contentId}, result: ${res}`);
-    }).catch((err: Error) => {
-      logger.error(`failed to cache content ${contentId}, error: ${err}`);
-    });
-
-  public cacheForChat = (postId: string, target: IChat) => {
-    const targetStr = this.chatAsString(target);
-    return new Promise<'OK'>((resolve, reject) =>
-      this.client.set(`sent/${targetStr}/${postId}`, 'true', 'EX', this.expireAfter, (err, res) =>
-        err ? reject(err) : resolve(res)
-      )
-    ).then(res => {
-      logger.debug(`cached post ${postId} for ${targetStr}, result: ${res}`);
-    }).catch((err: Error) => {
-      logger.error(`failed to cache post ${postId} for ${targetStr}, error: ${err}`);
-    });
-  };
-
-  public getContent = (contentId: string) =>
-    new Promise<string>((resolve, reject) =>
-      this.client.get(`content/${contentId}`, (err, res) => err ? reject(err) : resolve(res))
-    ).then(res => {
-      logger.debug(`retrieved cached content ${contentId}, result: ${res}`);
-      return res;
-    }).catch((err: Error) => {
-      logger.error(`failed to retrieve cached content ${contentId}, error: ${err}`);
-      throw err;
-    });
-
-  public isCachedForChat = (postId: string, target: IChat) => {
-    const targetStr = this.chatAsString(target);
-    return new Promise<number>((resolve, reject) =>
-      this.client.exists(`sent/${targetStr}/${postId}`, (err, res) => err ? reject(err) : resolve(res))
-    ).then(res => {
-      logger.debug(`retrieved status of post ${postId} for ${targetStr}, result: ${res}`);
-      return Boolean(res);
-    }).catch((err: Error) => {
-      logger.error(`failed to retrieve status of post ${postId} for ${targetStr}, error: ${err}`);
-      return false;
-    });
-  };
-}

+ 102 - 382
src/twitter.ts

@@ -1,66 +1,25 @@
-import * as fs from 'fs';
-import * as path from 'path';
 import * as Twitter from 'twitter';
 import TwitterTypes from 'twitter-d';
 
 import { getLogger } from './loggers';
 import QQBot from './koishi';
-import RedisSvc from './redis';
 import { chainPromises, BigNumOps } from './utils';
-import Webshot from './webshot';
 
 interface IWorkerOption {
-  lock: ILock;
-  lockfile: string;
   bot: QQBot;
-  workInterval: number;
-  webshotDelay: number;
-  consumerKey: string;
-  consumerSecret: string;
-  accessTokenKey: string;
-  accessTokenSecret: string;
-  mode: number;
-  wsUrl: string;
-  redis?: IRedisConfig;
+  privateAuthToken: string;
+  privateCsrfToken: string;
 }
 
-export class ScreenNameNormalizer {
-
-  // tslint:disable-next-line: variable-name
-  public static _queryUser: (username: string) => Promise<string>;
-
-  public static normalize = (username: string) => username.toLowerCase().replace(/^@/, '');
-
-  public static async normalizeLive(username: string) {
-    if (this._queryUser) {
-      return await this._queryUser(username)
-        .catch((err: {code: number, message: string}[]) => {
-          if (err[0].code !== 50) {
-            logger.warn(`error looking up user: ${err[0].message}`);
-            return username;
-          }
-          return null;
-        });
-    }
-    return this.normalize(username);
-  }
-}
-
-export let sendTweet = (id: string, receiver: IChat, forceRefresh: boolean): void => {
-  throw Error();
-};
-
-export interface ITimelineQueryConfig {
-  username: string;
+interface ISearchQueryConfig {
+  query: string;
   count?: number;
   since?: string;
   until?: string;
-  noreps?: boolean;
-  norts?: boolean;
 }
 
-export let sendTimeline = (
-  conf: {[key in keyof ITimelineQueryConfig]: string},
+export let doSearch = (
+  conf: {[key in keyof ISearchQueryConfig]: string},
   receiver: IChat
 ): void => {
   throw Error();
@@ -71,386 +30,147 @@ const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
   BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
 
 const logger = getLogger('twitter');
-const maxTrials = 3;
-const retryInterval = 1500;
-const ordinal = (n: number) => {
-  switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
-    case 1:
-      return `${n}st`;
-    case 2:
-      return `${n}nd`;
-    case 3:
-      return `${n}rd`;
-    default:
-      return `${n}th`;
-  }
-};
-const retryOnError = <T, U>(
-  doWork: () => Promise<T>,
-  onRetry: (error, count: number, terminate: (defaultValue: U) => void) => void
-) => new Promise<T | U>(resolve => {
-  const retry = (reason, count: number) => {
-    setTimeout(() => {
-      let terminate = false;
-      onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
-      if (!terminate) doWork().then(resolve).catch(error => retry(error, count + 1));
-    }, retryInterval);
-  };
-  doWork().then(resolve).catch(error => retry(error, 1));
-});
 
 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 {
+interface Tweet extends TwitterTypes.Status {
   user: FullUser;
   retweeted_status?: Tweet;
 }
 
-export type Tweet = ITweet;
-export type Tweets = ITweet[];
+namespace AdaptiveSearch {
+  export interface Response {
+    globalObjects: {
+      tweets: {[key: number]: Tweet & {user_id: number, user_id_str: string}};
+      users: {[key: number]: FullUser};
+      topics: {[key: number]: {[key: string]: any}};
+      [key: string]: any;
+    };
+    timeline: {
+      id: string;
+      instructions: [{
+        addEntries: {entries: TimelineEntry[]};
+      }];
+    };
+  }
+  
+  export interface TimelineEntry {
+    entryId: string;
+    sortIndex: string;
+    content: {item: object} | {operation: object};
+  }
+  
+  export interface TimelineCursor extends TimelineEntry {
+    content: {
+      operation: {cursor: {value: string}}
+    }
+  };
+  
+  export const isCursor = (entry: TimelineEntry): entry is TimelineCursor =>
+    entry.sortIndex === '0' || entry.sortIndex === '999999999';
+}
 
 export default class {
 
-  private client: Twitter;
-  private lock: ILock;
-  private lockfile: string;
-  private workInterval: number;
+  private privateClient: Twitter;
   private bot: QQBot;
-  private webshotDelay: number;
-  private webshot: Webshot;
-  private mode: number;
-  private wsUrl: string;
-  private redis: RedisSvc;
 
   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.lockfile = opt.lockfile;
-    this.lock = opt.lock;
-    this.workInterval = opt.workInterval;
+    this.privateClient = new Twitter({
+      bearer_token: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
+      request: {
+        headers: {
+          'Content-Type': 'application/x-www-form-urlencoded',
+          Cookie: `auth_token=${opt.privateAuthToken}; ct0=${opt.privateCsrfToken};`,
+          'X-CSRF-Token': opt.privateCsrfToken,
+        },
+      }
+    } as any);
     this.bot = opt.bot;
-    this.webshotDelay = opt.webshotDelay;
-    this.mode = opt.mode;
-    this.wsUrl = opt.wsUrl;
-    if (opt.redis) this.redis = new RedisSvc(opt.redis);
-    ScreenNameNormalizer._queryUser = this.queryUser;
-    sendTweet = (idOrQuery, receiver, forceRefresh) => {
-      const send = (id: string) => this.getTweet(
-        id,
-        this.sendTweets({sourceInfo: `tweet ${id}`, reportOnSkip: true, force: forceRefresh}, receiver),
-        forceRefresh
-      )
-        .catch((err: {code: number, message: string}[]) => {
-          if (err[0]?.code === 34)
-            return this.bot.sendTo(receiver, `找不到用户 ${match[2].replace(/^@?(.*)$/, '@$1')}。`);
-          if (err[0].code !== 144) {
-            logger.warn(`error retrieving tweet: ${err[0].message}`);
-            this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
-          }
-          this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
-        });
-      const match = /^last(|-\d+)@([^\/?#,]+)((?:,no.*?=[^,]*)*)$/.exec(idOrQuery);
-      const query = () => this.queryTimeline({
-          username: match[2],
-          count: 1 - Number(match[1]),
-          noreps: {on: true, off: false}[match[3].replace(/.*,noreps=([^,]*).*/, '$1')],
-          norts: {on: true, off: false}[match[3].replace(/.*,norts=([^,]*).*/, '$1')],
-        }).then(tweets => tweets.slice(-1)[0].id_str);
-      (match ? query() : Promise.resolve(idOrQuery)).then(send);
-    };
-    sendTimeline = ({username, count, since, until, noreps, norts}, receiver) => {
-      const countNum = Number(count) || 10;
-      (countNum > 0 ? this.queryTimeline : this.queryTimelineReverse)({
-        username,
+    doSearch = ({query, count, since, until}, receiver) => {
+      const countNum = Number(count) || 15;
+      this.search({
+        query,
         count: Math.abs(countNum),
         since: BigNumOps.parse(since) || snowflake(new Date(since).getTime()),
         until: BigNumOps.parse(until) || snowflake(new Date(until).getTime()),
-        noreps: {on: true, off: false}[noreps],
-        norts: {on: true, off: false}[norts],
       })
         .then(tweets => chainPromises(
           tweets.map(tweet => () => this.bot.sendTo(receiver, `\
+用户:${tweet.user.name} (@${tweet.user.screen_name})
 编号:${tweet.id_str}
 时间:${tweet.created_at}
 媒体:${tweet.extended_entities ? '有' : '无'}
 正文:\n${tweet.full_text.replace(/^([\s\S\n]{50})[\s\S\n]+?( https:\/\/t.co\/.*)?$/, '$1…$2')}`
           ))
             .concat(() => this.bot.sendTo(receiver, tweets.length ?
-              '时间线查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' :
-              '时间线查询完毕,没有找到符合条件的推文。'
+              '自定义查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' :
+              '自定义查询完毕,没有找到符合条件的推文。'
             ))
         ))
         .catch((err: {code: number, message: string}[]) => {
-          if (err[0]?.code !== 34) {
-            logger.warn(`error retrieving timeline: ${err[0]?.message || err}`);
-            return this.bot.sendTo(receiver, `获取时间线时出现错误:${err[0]?.message || err}`);
-          }
-          this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
+          logger.warn(`error retrieving timeline: ${err[0]?.message || err}`);
+          return this.bot.sendTo(receiver, `自定义查询时出现错误:${err[0]?.message || err}`);
         });
     };
   }
 
-  public launch = () => {
-    this.webshot = new Webshot(
-      this.wsUrl,
-      this.mode,
-      () => setTimeout(this.work, this.workInterval * 1000)
-    );
-  };
-
-  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);
-      }
-      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,
+  public search = (conf: ISearchQueryConfig) => {
+    for (const key in conf) {
+      if (!conf[key]) delete conf[key];
+    }
+    const origQuery = conf.query;
+    delete conf.query;
+    const paramNames = {until: 'max_id', since: 'since_id'};
+    logger.info(`performing custom query ${origQuery} with config: ${JSON.stringify(conf)}`);
+    const query = origQuery.replace(
+      new RegExp(`(${Object.values(paramNames).join('|')}):\d+`, 'g'),
+      ($0, $1) => conf[$1] ? '' : $0
+    ).replace(/\(\)/g, '') +
+      Object.keys(paramNames).map(
+        k => conf[k] && `(${paramNames[k]}:${conf[k]})`
+      ).join();
+    const doSearch = (q: string, tweets: Tweet[] = [], cursor?: string): Promise<Tweet[]> =>
+      this.privateClient.get('https://api.twitter.com/2/search/adaptive.json', {
+        q,
         tweet_mode: 'extended',
-      },
-      tweets: ITweet[] = []
-    ): Promise<ITweet[]> => this.client.get('statuses/user_timeline', config)
-      .then((newTweets: ITweet[]) => {
+        count: 20,
+        include_entities: true,
+        query_source: 'typed_query',
+        ...(cursor && {cursor}),
+      }).then(({
+        globalObjects: {tweets: tweetDict, users: userDict},
+        timeline: {instructions: {0: {addEntries: {entries}}}}
+      }: AdaptiveSearch.Response) => {
+        const newTweets = Object.values(tweetDict);
+        let bottomCursor: AdaptiveSearch.TimelineCursor;
         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 ${
+          entries.sort(
+            (e1, e2) => Number(e1.sortIndex) - Number(e2.sortIndex)
+          )
+          if (AdaptiveSearch.isCursor(entries[0])) bottomCursor = entries[0];
+          logger.info(`custom query ${origQuery} yielded ${
             newTweets.length
-          } new tweets, next query will start at offset ${config.max_id}`);
+          } new tweets, next query will follow tweet ${
+            entries[1].entryId.replace(/^sq-I-t-/, '')
+          }`);
+          newTweets.forEach(tweet => Object.assign(tweet, {
+            user: userDict[tweet.user_id_str],
+          }));
           tweets.push(...newTweets);
         }
-        if (!newTweets.length || tweets.length >= count) {
-          logger.info(`timeline query of ${username} finished successfully, ${
+        if (!newTweets.length || tweets.length >= conf.count) {
+          logger.info(`custom query ${origQuery} finished successfully, ${
             tweets.length
           } tweets have been fetched`);
-          return tweets.slice(0, count);
-        }
-        return fetchTimeline(config, tweets);
-      });
-    return fetchTimeline();
-  };
-
-  private workOnTweets = (
-    tweets: Tweets,
-    sendTweets: (id: string, msg: string, text: string, author: string) => void,
-    refresh = false
-  ) => Promise.all(tweets.map(tweet => 
-    ((this.redis && !refresh) ? this.redis.getContent(`webshot/${tweet.id_str}`) : Promise.reject())
-      .then(content => {
-        if (content === null) throw Error();
-        logger.info(`retrieved cached webshot of tweet ${tweet.id_str} from redis database`);
-        const {msg, text, author} = JSON.parse(content) as {[key: string]: string};
-        sendTweets(tweet.retweeted_status ? tweet.retweeted_status.id_str : tweet.id_str, msg, text, author);
-      }).catch(() =>
-        this.webshot([tweet], (id: string, msg: string, text: string, author: string) => {
-          Promise.resolve()
-            .then(() => {
-              if (!this.redis) return;
-              logger.info(`caching webshot of tweet ${tweet.id_str} to redis database`);
-              this.redis.cacheContent(`webshot/${tweet.id_str}`, JSON.stringify({msg, text, author}));
-            })
-            .then(() => sendTweets(id, msg, text, author));
-        }, this.webshotDelay)
-      )
-  ));
-
-  public getTweet = (id: string, sender: (id: string, msg: string, text: string, author: string) => void, refresh = false) => {
-    const endpoint = 'statuses/show';
-    const config = {
-      id,
-      tweet_mode: 'extended',
-    };
-    return this.client.get(endpoint, config)
-      .then((tweet: Tweet) => {
-        logger.debug(`api returned tweet ${JSON.stringify(tweet)} for query id=${id}`);
-        return this.workOnTweets([tweet], sender, refresh);
-      });
-  };
-
-  private sendTweets = (
-    config: {sourceInfo?: string, reportOnSkip?: boolean, force?: boolean} = {reportOnSkip: false, force: false},
-    ...to: IChat[]
-  ) => (id: string, msg: string, text: string, author: string) => {
-    to.forEach(subscriber => {
-      const {sourceInfo: source, reportOnSkip, force} = config;
-      const targetStr = JSON.stringify(subscriber);
-      const send = () => retryOnError(
-        () => this.bot.sendTo(subscriber, msg),
-        (_, count, terminate: (doNothing: Promise<void>) => void) => {
-          if (count <= maxTrials) {
-            logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-          } else {
-            logger.warn(`${count - 1} consecutive failures while sending message chain, trying plain text instead...`);
-            terminate(this.bot.sendTo(subscriber, author + text, true));
-          }
-        }
-      ).then(() => {
-        if (this.redis) {
-          logger.info(`caching push status of this tweet (or its origin in case of a retweet) for ${targetStr}...`);
-          return this.redis.cacheForChat(id, subscriber);
+          return tweets.slice(0, conf.count);
         }
+        return doSearch(query, tweets, bottomCursor?.content.operation.cursor.value);
       });
-      ((this.redis && !force) ? this.redis.isCachedForChat(id, subscriber) : Promise.resolve(false))
-        .then(isCached => {
-          if (isCached) {
-            logger.info(`skipped subscriber ${targetStr} as this tweet or the origin of this retweet has been sent already`);
-            if (!reportOnSkip) return;
-            text = `[最近发送过的推文:${id}]`;
-            msg = author + text;
-          }
-          logger.info(`pushing data${source ? ` of ${source}` : ''} to ${targetStr}`);
-          return send();
-        });
-    });
-  };
-
-  public work = () => {
-    const lock = this.lock;
-    if (this.workInterval < 1) this.workInterval = 1;
-    if (lock.feed.length === 0) {
-      setTimeout(() => {
-        this.work();
-      }, this.workInterval * 1000);
-      return;
-    }
-    if (lock.workon >= lock.feed.length) lock.workon = 0;
-    if (!lock.threads[lock.feed[lock.workon]] ||
-      !lock.threads[lock.feed[lock.workon]].subscribers ||
-      lock.threads[lock.feed[lock.workon]].subscribers.length === 0) {
-      logger.warn(`nobody subscribes thread ${lock.feed[lock.workon]}, removing from feed`);
-      delete lock.threads[lock.feed[lock.workon]];
-      lock.feed.splice(lock.workon, 1);
-      fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-      this.work();
-      return;
-    }
-
-    const currentFeed = lock.feed[lock.workon];
-    logger.debug(`pulling feed ${currentFeed}`);
-
-    const promise = new Promise(resolve => {
-      let match = /https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/.exec(currentFeed);
-      let config: {[key: string]: any};
-      let endpoint: string;
-      if (match) {
-        if (match[1] === 'i') {
-          config = {
-            list_id: match[2],
-            tweet_mode: 'extended',
-          };
-        } else {
-          config = {
-            owner_screen_name: match[1],
-            slug: match[2],
-            tweet_mode: 'extended',
-          };
-        }
-        endpoint = 'lists/statuses';
-      } else {
-        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.threads[currentFeed].offset;
-        if (offset as unknown as number > 0) config.since_id = offset;
-        const getMore = (gotTweets: 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.threads[currentFeed].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 || tweets.length <= 1) return resolve(gotTweets);
-          config.max_id = tweets.slice(-1)[0].id_str;
-          getMore(gotTweets.concat(tweets));
-        });
-        getMore();
-      }
-    });
-
-    promise.then((tweets: Tweets) => {
-      logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
-      const currentThread = lock.threads[currentFeed];
-
-      const updateDate = () => currentThread.updatedAt = new Date().toString();
-      if (!tweets || tweets.length === 0) { updateDate(); return; }
-
-      const topOfFeed = tweets[0].id_str;
-      const updateOffset = () => currentThread.offset = topOfFeed;
-
-      if (currentThread.offset === '-1') { updateOffset(); return; }
-      if (currentThread.offset === '0') tweets.splice(1);
-
-      return this.workOnTweets(tweets, this.sendTweets({sourceInfo: `thread ${currentFeed}`}, ...currentThread.subscribers))
-        .then(updateDate).then(updateOffset);
-    })
-      .then(() => {
-        lock.workon++;
-        let timeout = this.workInterval * 1000 / lock.feed.length;
-        if (timeout < 1000) timeout = 1000;
-        fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-        setTimeout(() => {
-          this.work();
-        }, timeout);
-      });
+    return doSearch(query);
   };
 }

+ 0 - 436
src/webshot.ts

@@ -1,436 +0,0 @@
-import { writeFileSync } from 'fs';
-import { Readable } from 'stream';
-import { promisify } from 'util';
-
-import axios from 'axios';
-import * as CallableInstance from 'callable-instance';
-import { XmlEntities } from 'html-entities';
-import { PNG } from 'pngjs';
-import * as puppeteer from 'playwright';
-import * as sharp from 'sharp';
-import * as temp from 'temp';
-
-import { getLogger } from './loggers';
-import { Message } from './koishi';
-import { MediaEntity, Tweets } from './twitter';
-import { chainPromises } from './utils';
-
-const xmlEntities = new XmlEntities();
-
-const ZHType = (type: string) => new class extends String {
-  public type = super.toString();
-  public toString = () => `[${super.toString()}]`;
-}(type);
-
-const typeInZH = {
-  photo: ZHType('图片'),
-  video: ZHType('视频'),
-  animated_gif: ZHType('GIF'),
-};
-
-const logger = getLogger('webshot');
-
-class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Promise<void>> {
-
-  private browser: puppeteer.Browser;
-  private mode: number;
-  private wsUrl: string;
-
-  constructor(wsUrl: string, mode: number, onready?: (...args) => void) {
-    super('webshot');
-    // tslint:disable-next-line: no-conditional-assignment
-    // eslint-disable-next-line no-cond-assign
-    if (this.mode = mode) {
-      onready();
-    } else {
-      this.wsUrl = wsUrl;
-      this.connect(onready);
-    }
-  }
-
-  private connect = (onready?: (...args) => void): Promise<void> =>
-    axios.get<{[key in 'chromium' | 'firefox' | 'webkit']?: string}>(this.wsUrl)
-      .then(res => {
-        logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
-        const browserType = Object.keys(res.data)[0] as keyof typeof res.data;
-        return (puppeteer[browserType] as puppeteer.BrowserType<puppeteer.Browser>)
-          .connect({wsEndpoint: res.data[browserType]});
-      })
-      .then(browser => this.browser = browser)
-      .then(() => {
-        logger.info('launched puppeteer browser');
-        if (onready) return onready();
-      })
-      .catch(error => this.reconnect(error, onready));
-
-  private reconnect = (error, onready?: (...args) => void) => {
-    logger.error(`connection error, reason: ${error}`);
-    logger.warn('trying to reconnect in 2.5s...');
-    return promisify(setTimeout)(2500)
-      .then(() => this.connect(onready));
-  };
-
-  private extendEntity = (media: MediaEntity) => {
-    logger.info('not working on a tweet');
-  };
-
-  private truncateLongThread = (atId: string) => {
-    logger.info('not working on a tweet');
-  };
-
-  private renderWebshot = (
-    url: string, height: number, webshotDelay: number,
-    ...morePostProcessings: ((page: puppeteer.Page) => Promise<any>)[]
-  ): Promise<string> => {
-    temp.track();
-    const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
-    const sharpToFile = (pic: sharp.Sharp) => new Promise<string>(resolve => {
-      const webshotTempFilePath = temp.path({suffix: '.jpg'});
-      pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
-    });
-    const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
-      const width = 720;
-      const zoomFactor = 2;
-      logger.info(`shooting ${width}*${height} webshot for ${url}`);
-      this.browser.newPage({
-        bypassCSP: true,
-        deviceScaleFactor: zoomFactor,
-        locale: 'ja-JP',
-        timezoneId: 'Asia/Tokyo',
-        userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
-      })
-        .then(page => {
-          const startTime = new Date().getTime();
-          const getTimerTime = () => new Date().getTime() - startTime;
-          const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
-          page.setViewportSize({
-            width: width / zoomFactor,
-            height: height / zoomFactor,
-          })
-            .then(() => page.route('*:\/\/video.twimg.com\/**', route => { route.abort(); }))
-            .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-            .then(() => Promise.race([
-              page.waitForSelector('article', {state: 'attached', timeout: getTimeout()}),
-              page.click('#placeholder+#ScriptLoadFailure input[value="Try again"]', {timeout: getTimeout()}),
-              page.waitForSelector('div[role="button"]>div>span>:text-matches("^やりなおす|更新$")', {state: 'attached', timeout: getTimeout()})
-                .then(() => page.reload({timeout: getTimeout()})),
-            ]))
-            // hide header, "more options" button, like and retweet count
-            .then(() => page.addStyleTag({
-              content: 'header,#layers{display:none!important}article{background-color:transparent!important}' +
-                '[data-testid="caret"],[role="group"],[data-testid="tweet"] [class*=" "]+:last-child>*+[class*=" "]~div{display:none}',
-            }))
-            .then(() => page.addStyleTag({
-              content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
-            }))
-            // remove listeners
-            .then(() => page.evaluate(() => {
-              const poll = setInterval(() => {
-                document.querySelectorAll('div[data-testid="placementTracking"]').forEach(container => {
-                  if (container.querySelector('div[role="button"] svg')) {
-                    container.innerHTML = container.innerHTML;
-                    clearInterval(poll);
-                  }
-                });
-              }, 250);
-            }))
-            // find main tweet
-            .then(() => page.waitForSelector(
-              'xpath=//section/*/*/div[.//article[not(.//time[not(ancestor::div[@aria-labelledby])])]]',
-              {state: 'attached', timeout: getTimeout()}
-            ))
-            // toggle visibility of sensitive tweets
-            .then(handle => handle.$$('xpath=..//a[contains(@href,"content_you_see")]/../../..//*[@role="button"]')
-              .then(sensitiveToggles => {
-                const count = sensitiveToggles.length;
-                if (count) logger.info(`found ${count} sensitive ${count === 1 ? 'tweet' : 'tweets'} on page, uncollapsing...`);
-                return chainPromises(sensitiveToggles.filter(toggle => toggle.isVisible()).map(toggle => () => toggle.click()));
-              })
-              .then(() => handle)
-            )
-            // throw early if tweet is unavailable
-            .then(handle => handle.$('[data-testid="tweet"]').then(owner => owner ? handle : null))
-            .catch((err: Error): Promise<puppeteer.ElementHandle<HTMLDivElement> | null> => {
-              if (err.name !== 'TimeoutError')
-              throw err;
-              logger.warn(`${err} (${getTimerTime()} ms)`);
-              return page.evaluate(() => document.documentElement.outerHTML).then(html => {
-                const path = temp.path({ suffix: '.html' });
-                writeFileSync(path, html);
-                logger.warn(`saved debug html to ${path}`);
-              }).then(() => page.screenshot()).then(screenshot => {
-                sharpToFile(sharp(screenshot).jpeg({ quality: 90 })).then(fileUri => {
-                  logger.warn(`saved debug screenshot to ${fileUri.substring(7)}`);
-                });
-              }).then(() => null);
-            })
-            // scroll back at least 2 tweets revealing 2nd last tweet by owner in thread, or top of thread, if any
-            .then((handle: puppeteer.ElementHandle<HTMLDivElement>) => {
-              if (handle === null) throw new puppeteer.errors.TimeoutError();
-              return handle.evaluate(div => {
-                try {
-                  const selector = '[data-testid="tweet"] :nth-child(2)>:first-child a';
-                  const getProfileUrl = () => (div.querySelector<HTMLAnchorElement>(selector) || {href: ''}).href;
-                  const ownerProfileUrl = getProfileUrl();
-                  const bottom = div;
-                  // eslint-disable-next-line no-cond-assign
-                  while (div = div.previousElementSibling as HTMLDivElement) {
-                    if (getProfileUrl() !== ownerProfileUrl || div === bottom.previousElementSibling) continue;
-                    const top = document.documentElement.scrollTop = window.scrollY + div.getBoundingClientRect().top;
-                    if (top > 10)
-                      return div.querySelector<HTMLAnchorElement>('article a[aria-label]').href.replace(/.*\/status\//, '');
-                  }
-                } catch {/* handle errors like none-found cases */}
-                document.documentElement.scrollTop = 0;
-              }).then(this.truncateLongThread).then(() => handle);
-            })
-            // scrape card image from main tweet
-            .then(handle => handle.evaluate(div => {
-              const cardImg = div.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
-              if (typeof cardImg?.getAttribute('src') === 'string') {
-                const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg?.getAttribute('src'));
-                if (match) {
-                  // tslint:disable-next-line: variable-name
-                  const [media_url_https, id_str] = match.slice(1);
-                  return {
-                    media_url: media_url_https.replace(/^https/, 'http'),
-                    media_url_https,
-                    url: '',
-                    display_url: '',
-                    expanded_url: '',
-                    type: 'photo',
-                    id: Number(id_str),
-                    id_str,
-                    sizes: undefined,
-                  };
-                }
-              }
-            }))
-            .then(cardImg => {
-              if (cardImg) this.extendEntity(cardImg); 
-            })
-            .then(() => chainPromises(morePostProcessings.map(func => () => func(page))))
-            .then(() => promisify(setTimeout)(getTimeout()))
-            // hide highlight of retweet header
-            .then(() => page.evaluate(() => (document.activeElement as unknown as HTMLOrSVGElement).blur()))
-            .then(() => page.screenshot())
-            .then(screenshot => {
-              new PNG({
-                filterType: 4,
-                deflateLevel: 0,
-              }).on('parsed', function () {
-                // remove comment area
-                // tslint:disable-next-line: no-shadowed-variable
-                // eslint-disable-next-line @typescript-eslint/no-shadow
-                const idx = (x: number, y: number) => (this.width * y + x) << 2;
-                let boundary: number = null;
-                const x = zoomFactor * 2;
-                for (let y = x; y < this.height; y += zoomFactor) {
-                  if (
-                    this.data[idx(x, y)] !== this.data[idx(x, y - zoomFactor)] &&
-                    this.data[idx(x, y)] === this.data[idx(x + zoomFactor * 10, y)]
-                  ) {
-                    boundary = y;
-                    break;
-                  }
-                }
-                if (boundary !== null) {
-                  logger.info(`found boundary at ${boundary}, cropping image`);
-                  this.data = this.data.slice(0, idx(this.width, boundary));
-                  this.height = boundary;
-
-                  sharpToFile(jpeg(this.pack())).then(path => {
-                    logger.info(`finished webshot for ${url}`);
-                    resolve({path, boundary});
-                  });
-                } else if (height >= 8 * 1920) {
-                  logger.warn('too large, consider as a bug, returning');
-                  sharpToFile(jpeg(this.pack())).then(path => {
-                    resolve({path, boundary: 0});
-                  });
-                } else {
-                  logger.info('unable to find boundary, try shooting a larger image');
-                  resolve({path: '', boundary});
-                }
-              }).parse(screenshot);
-            })
-            .catch(err => {
-              if (err instanceof Error && err.name !== 'TimeoutError') throw err;
-              logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-              resolve({path: '', boundary: 0});
-            })
-            .finally(() => { page.close(); });
-        })
-        .catch(reject);
-    });
-    return promise.then(data => {
-      if (data.boundary === null) {
-        return this.renderWebshot(url, height + 1920, webshotDelay, ...morePostProcessings);
-      } else return data.path;
-    }).catch(error => this.reconnect(error)
-      .then(() => this.renderWebshot(url, height, webshotDelay, ...morePostProcessings))
-    );
-  };
-
-  private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
-    logger.info(`fetching ${url}`);
-    axios({
-      method: 'get',
-      url,
-      responseType: 'arraybuffer',
-      timeout: 150000,
-    }).then(res => {
-      if (res.status === 200) {
-        logger.info(`successfully fetched ${url}`);
-        resolve(res.data);
-      } else {
-        logger.error(`failed to fetch ${url}: ${res.status}`);
-        reject();
-      }
-    }).catch (err => {
-      logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
-      reject();
-    });
-  }).then(data =>
-    (ext => {
-      const mediaTempFilePath = temp.path({suffix: `.${ext}`});
-      writeFileSync(mediaTempFilePath, Buffer.from(data));
-      const path = `file://${mediaTempFilePath}`;
-      switch (ext) {
-        case 'jpg':
-        case 'png':
-          return Message.Image(path);
-        case 'mp4':
-          return Message.Video(path);
-      }
-      logger.warn('unable to find MIME type of fetched media, failing this fetch');
-      throw Error();
-    })(((/\?format=([a-z]+)&/.exec(url)) ?? (/.*\/.*\.([^?]+)/.exec(url)))[1])
-  );
-
-  public webshot(
-    tweets: Tweets,
-    callback: (twiId: string, msgs: string, text: string, author: string) => void,
-    webshotDelay: number
-  ): Promise<void> {
-    let promise = new Promise<void>(resolve => {
-      resolve();
-    });
-    tweets.forEach(twi => {
-      promise = promise.then(() => {
-        logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
-      });
-      const originTwi = twi.retweeted_status || twi;
-      let messageChain = '';
-      let truncatedAt: string;
-
-      // text processing
-      let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
-      if (twi.retweeted_status) author += `RT @${twi.retweeted_status.user.screen_name}: `;
-
-      let text = originTwi.full_text;
-
-      promise = promise.then(() => {
-        if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
-          originTwi.entities.urls.forEach(url => {
-            text = text.replace(new RegExp(url.url, 'gm'), url.expanded_url);
-          });
-        }
-        if (originTwi.extended_entities) {
-          originTwi.extended_entities.media.forEach(media => {
-            text = text.replace(new RegExp(media.url, 'gm'), this.mode === 1 ? typeInZH[media.type] : '');
-          });
-        }
-        if (this.mode > 0) messageChain += (author + xmlEntities.decode(text));
-      });
-
-      // invoke webshot
-      if (this.mode === 0) {
-        const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
-        this.extendEntity = (cardImg: MediaEntity) => {
-          originTwi.extended_entities = {
-            ...originTwi.extended_entities,
-            media: [
-              ...originTwi.extended_entities?.media ?? [],
-              cardImg,
-            ],
-          };
-        };
-        this.truncateLongThread = (atId: string) => {
-          if (!atId) return;
-          logger.info(`thread too long, truncating at tweet ${atId}...`);
-          truncatedAt = atId;
-        };
-        promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-          .then(fileurl => {
-            if (fileurl) return Message.Image(fileurl);
-            return '[截图不可用] ' + author + text;
-          })
-          .then(msg => {
-            if (msg) messageChain += msg;
-          });
-      }
-      // fetch extra entities
-      // tslint:disable-next-line: curly
-      // eslint-disable-next-line curly
-      if (1 - this.mode % 2) promise = promise.then(() => {
-        if (originTwi.extended_entities) {
-          return chainPromises(originTwi.extended_entities.media.map(media => () => {
-            let url: string;
-            if (media.type === 'photo') {
-              url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';
-            } else {
-              url = media.video_info.variants
-                .filter(variant => variant.bitrate !== undefined)
-                .sort((var1, var2) => var2.bitrate - var1.bitrate)
-                .map(variant => variant.url)[0]; // largest video
-            }
-            const altMessage = `\n[失败的${typeInZH[media.type as keyof typeof typeInZH].type}:${url}]`;
-            return this.fetchMedia(url)
-              .catch(error => {
-                logger.warn('unable to fetch media, sending plain text instead...');
-                return altMessage;
-              })
-              .then(msg => { messageChain += msg; });
-          }));
-        }
-      });
-      // append URLs, if any
-      if (this.mode === 0) {
-        if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
-          promise = promise.then(() => {
-            const urls = originTwi.entities.urls
-              .filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
-              .map(urlObj => `\n\ud83d\udd17 ${urlObj.expanded_url}`);
-            if (urls.length) {
-              messageChain += urls.join('');
-            }
-          });
-        }
-      }
-      // refer to earlier tweets if thread is truncated
-      promise = promise.then(() => {
-        if (truncatedAt) {
-          messageChain += `\n回复此命令查看对话串中更早的推文:\n/twitter_view ${truncatedAt}`;
-        }
-      });
-      // refer to quoted tweet, if any
-      if (originTwi.is_quote_status) {
-        promise = promise.then(() => {
-          const match = /\/status\/(\d+)/.exec(originTwi.quoted_status_permalink?.expanded);
-          const blockQuoteIdStr = match ? match[1] : originTwi.quoted_status?.id_str;
-          if (blockQuoteIdStr) messageChain += `\n回复此命令查看引用的推文:\n/twitter_view ${blockQuoteIdStr}`;
-        });
-      }
-      promise.then(() => {
-        logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-        logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
-        const twiId = twi.retweeted_status ? twi.retweeted_status.id_str : twi.id_str;
-        callback(twiId, messageChain, xmlEntities.decode(text), author);
-      });
-    });
-    return promise;
-  }
-}
-
-export default Webshot;

File diff suppressed because it is too large
+ 0 - 10
src/webshot_test.js


Some files were not shown because too many files changed in this diff