Mike L 4 gadi atpakaļ
vecāks
revīzija
eb99b18038
13 mainītis faili ar 629 papildinājumiem un 996 dzēšanām
  1. 5 5
      config.example.json
  2. 50 102
      dist/command.js
  3. 12 37
      dist/koishi.js
  4. 16 11
      dist/main.js
  5. 159 168
      dist/twitter.js
  6. 66 153
      dist/webshot.js
  7. 3 4
      package.json
  8. 43 104
      src/command.ts
  9. 13 38
      src/koishi.ts
  10. 17 11
      src/main.ts
  11. 1 0
      src/model.d.ts
  12. 182 218
      src/twitter.ts
  13. 62 145
      src/webshot.ts

+ 5 - 5
config.example.json

@@ -3,15 +3,15 @@
   "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": "",
+  "ig_username": "",
+  "ig_password": "",
+  "ig_session_lockfile": "",
   "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,
+  "webshot_delay": 20000,
+  "webshot_cookies_lockfile": "",
   "lockfile": "subscriber.lock",
   "loglevel": "info"
 }

+ 50 - 102
dist/command.js

@@ -1,6 +1,6 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.query = exports.view = exports.unsub = exports.list = exports.sub = exports.parseCmd = void 0;
+exports.view = exports.unsub = exports.list = exports.sub = exports.parseCmd = void 0;
 const fs = require("fs");
 const path = require("path");
 const datetime_1 = require("./datetime");
@@ -28,30 +28,8 @@ 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());
+function linkFinder(userName, chat, lock) {
+    const normalizedLink = twitter_1.linkBuilder({ userName });
     const link = Object.keys(lock.threads).find(realLink => normalizedLink === realLink.replace(/\/@/, '/').toLowerCase());
     if (!link)
         return [null, -1];
@@ -65,27 +43,20 @@ function sub(chat, args, reply, lock, lockfile) {
     if (args.length === 0) {
         return reply('找不到要订阅的链接。');
     }
-    const match = parseLink(args[0]);
-    if (!match) {
+    const matched = twitter_1.parseLink(args[0]);
+    if (!matched) {
         return reply(`订阅链接格式错误:
 示例:
-https://twitter.com/Saito_Shuka
-https://twitter.com/rikakomoe/lists/lovelive
-https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
+https://www.instagram.com/tomoyo_kurosawa_/
+https://www.instagram.com/p/B6GHRSmgV-7/`);
     }
     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) {
+        const { id, msg = `已为此聊天订阅 ${link}` } = config;
+        if (id) {
             lock.feed.push(link);
             lock.threads[link] = {
+                id,
                 offset,
                 subscribers: [],
                 updatedAt: '',
@@ -96,35 +67,51 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
         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 });
+    const tryFindSub = (userName) => {
+        const [realLink, index] = linkFinder(userName, chat, lock);
+        if (index > -1) {
+            reply('此聊天已订阅此链接。');
+            return true;
+        }
+        if (realLink) {
+            subscribeTo(realLink);
+            return true;
+        }
+        return false;
+    };
+    const newSub = (userName) => {
+        const link = twitter_1.linkBuilder(matched);
+        subscribeTo(link, { id: Number(userName.split(':')[1]) });
+    };
+    if (matched.postUrlSegment) {
+        offset = utils_1.BigNumOps.plus(twitter_1.urlSegmentToId(matched.postUrlSegment), '-1');
+        delete matched.postUrlSegment;
+        twitter_1.getPostOwner(matched.postUrlSegment).then(userName => {
+            if (!tryFindSub(userName))
+                newSub(userName);
+        }).catch((parsedErr) => {
+            reply(parsedErr.message);
+        });
+    }
+    else if (!tryFindSub(matched.userName)) {
+        twitter_1.ScreenNameNormalizer.normalizeLive(matched.userName).then(userName => {
+            if (!userName)
+                return reply(`找不到用户 ${matched.userName.replace(/^@?(.*)$/, '@$1')}。`);
+            else
+                newSub(userName);
+        });
     }
-    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 unsub(chat, args, reply, lock, lockfile) {
+    var _a;
     if (chat.chatType === "temp") {
         return reply('请先添加机器人为好友。');
     }
     if (args.length === 0) {
         return reply('找不到要退订的链接。');
     }
-    const match = parseLink(args[0]);
+    const match = (_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.userName;
     if (!match) {
         return reply('链接格式有误。');
     }
@@ -148,62 +135,23 @@ function list(chat, _, reply, lock) {
         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'));
+    return reply('此聊天中订阅的 Instagram 动态链接:\n' + links.join('\n'));
 }
 exports.list = list;
 function view(chat, args, reply) {
+    var _a;
     if (args.length === 0) {
         return reply('找不到要查看的链接。');
     }
-    const match = /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
+    const match = twitter_1.isValidUrlSegment(args[0]) && args[0] || ((_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.postUrlSegment);
     if (!match) {
         return reply('链接格式有误。');
     }
     try {
-        twitter_1.sendTweet(match[1], chat);
+        twitter_1.sendPost(match, chat);
     }
     catch (e) {
-        reply('推特机器人尚未加载完毕,请稍后重试。');
+        reply('机器人尚未加载完毕,请稍后重试。');
     }
 }
 exports.view = view;
-function query(chat, args, reply) {
-    if (args.length === 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 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);
-        if (!optMatch)
-            return reply(`未定义的查询参数:${arg}。`);
-        const optKey = optMatch[1];
-        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('查询数量上限参数为零、非数值或超出取值范围。');
-    }
-    try {
-        twitter_1.sendTimeline(conf, chat);
-    }
-    catch (e) {
-        logger.error(`error querying timeline, error: ${e}`);
-        reply('推特机器人尚未加载完毕,请稍后重试。');
-    }
-}
-exports.query = query;

+ 12 - 37
dist/koishi.js

@@ -149,57 +149,32 @@ class default_1 {
                 const cmdObj = command_1.parseCmd(session.content);
                 const reply = (msg) => __awaiter(this, void 0, void 0, function* () { return session.sendQueued(msg); });
                 switch (cmdObj.cmd) {
-                    case 'twitter_view':
-                    case 'twitter_get':
+                    case 'instagram_view':
+                    case 'instagram_get':
                         command_1.view(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':
+                    case 'instagram_sub':
+                    case 'instagram_subscribe':
                         this.botInfo.sub(chat, cmdObj.args, reply);
                         break;
-                    case 'twitter_unsub':
-                    case 'twitter_unsubscribe':
+                    case 'instagram_unsub':
+                    case 'instagram_unsubscribe':
                         this.botInfo.unsub(chat, cmdObj.args, reply);
                         break;
                     case 'ping':
-                    case 'twitter':
+                    case 'instagram':
                         this.botInfo.list(chat, cmdObj.args, reply);
                         break;
                     case 'help':
                         if (cmdObj.args.length === 0) {
-                            reply(`推特搬运机器人:
-/twitter - 查询当前聊天中的推文订阅
-/twitter_subscribe〈链接|用户名〉- 订阅 Twitter 推文搬运
-/twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
-/twitter_view〈链接〉- 查看推文
-/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
+                            reply(`Instagram 搬运机器人:
+/instagram - 查询当前聊天中的 Instagram 动态订阅
+/instagram_subscribe〈链接|用户名〉- 订阅 Instagram 媒体搬运
+/instagram_unsubscribe〈链接|用户名〉- 退订 Instagram 媒体搬运
+/instagram_view〈链接〉- 查看媒体
 ${chat.chatType === "temp" ?
                                 '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
                         }
-                        else if (cmdObj.args[0] === 'twitter_query') {
-                            reply(`查询时间线中的推文:
-/twitter_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
-
-参数列表(方框内全部为可选,留空则为默认):
-    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
-    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
-    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
-    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
-    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
-                                .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 条)`));
-                        }
                 }
             }), true);
         };

+ 16 - 11
dist/main.js

@@ -12,14 +12,14 @@ const twitter_1 = require("./twitter");
 const logger = loggers_1.getLogger();
 const sections = [
     {
-        header: 'MiraiTS Twitter Bot',
-        content: 'The QQ Bot that forwards twitters.',
+        header: 'GoCQHTTP Instagram Bot',
+        content: 'The QQ Bot that forwards Instagram.',
     },
     {
         header: 'Synopsis',
         content: [
-            '$ mirai-twitter-bot {underline config.json}',
-            '$ mirai-twitter-bot {bold --help}',
+            '$ cq-instagram-bot {underline config.json}',
+            '$ cq-instagram-bot {bold --help}',
         ],
     },
     {
@@ -47,7 +47,7 @@ catch (e) {
     process.exit(1);
 }
 const requiredFields = [
-    'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+    'ig_username', 'ig_password',
     'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
 ];
 const warningFields = [
@@ -62,11 +62,17 @@ if (requiredFields.some((value) => config[value] === undefined)) {
 }
 optionalFields.forEach(key => {
     if (config[key] === undefined || typeof (config[key]) !== typeof (exampleConfig[key])) {
-        if (key in warningFields)
+        if (warningFields.includes(key))
             logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
         config[key] = exampleConfig[key];
     }
 });
+['ig_session_lockfile', ...(config.mode || exampleConfig.mode) === 0 ? ['webshot_cookies_lockfile'] : []].forEach(key => {
+    if (!config[key]) {
+        logger.warn(`${key} is undefined, use <username>.${key.replace('_lockfile', '.lock')} as default`);
+        config[key] = `${config.ig_username}.${key.replace('_lockfile', '.lock')}`;
+    }
+});
 loggers_1.setLogLevels(config.loglevel);
 let lock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
@@ -117,17 +123,16 @@ const qq = new koishi_1.default({
     unsub: (c, a, cb) => command_1.unsub(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,
+    sessionLockfile: config.ig_session_lockfile,
+    credentials: [config.ig_username, config.ig_password],
     lock,
     lockfile: config.lockfile,
     workInterval: config.work_interval,
     bot: qq,
     webshotDelay: config.webshot_delay,
+    webshotCookiesLockfile: config.webshot_cookies_lockfile,
     mode: config.mode,
     wsUrl: config.playwright_ws_spec_endpoint,
 });
-worker.launch();
+worker.session.init().then(worker.launch);
 qq.connect();

+ 159 - 168
dist/twitter.js

@@ -9,23 +9,85 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.sendTimeline = exports.sendTweet = exports.ScreenNameNormalizer = void 0;
+exports.sendPost = exports.getPostOwner = exports.browserLogin = exports.ScreenNameNormalizer = exports.SessionManager = exports.urlSegmentToId = exports.idToUrlSegment = exports.isValidUrlSegment = exports.parseLink = exports.linkBuilder = void 0;
 const fs = require("fs");
 const path = require("path");
-const Twitter = require("twitter");
+const instagram_id_to_url_segment_1 = require("instagram-id-to-url-segment");
+Object.defineProperty(exports, "idToUrlSegment", { enumerable: true, get: function () { return instagram_id_to_url_segment_1.instagramIdToUrlSegment; } });
+Object.defineProperty(exports, "urlSegmentToId", { enumerable: true, get: function () { return instagram_id_to_url_segment_1.urlSegmentToInstagramId; } });
+const instagram_private_api_1 = require("instagram-private-api");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
 const utils_1 = require("./utils");
 const webshot_1 = require("./webshot");
+const parseLink = (link) => {
+    let match = /instagram\.com\/p\/([A-Za-z0-9\-_]+)/.exec(link);
+    if (match)
+        return { postUrlSegment: match[1] };
+    match =
+        /instagram\.com\/([^\/?#]+)/.exec(link) ||
+            /^([^\/?#]+)$/.exec(link);
+    if (match)
+        return { userName: ScreenNameNormalizer.normalize(match[1]).split(':')[0] };
+    return;
+};
+exports.parseLink = parseLink;
+const isValidUrlSegment = (input) => /^[A-Za-z0-9\-_]+$/.test(input);
+exports.isValidUrlSegment = isValidUrlSegment;
+const linkBuilder = (config) => {
+    if (config.userName)
+        return `https://www.instagram.com/${config.userName}/`;
+    if (config.postUrlSegment)
+        return `https://www.instagram.com/p/${config.postUrlSegment}/`;
+};
+exports.linkBuilder = linkBuilder;
+class SessionManager {
+    constructor(client, file, credentials) {
+        this.init = () => {
+            this.ig.state.generateDevice(this.username);
+            this.ig.request.end$.subscribe(() => { this.save(); });
+            const filePath = path.resolve(this.lockfile);
+            if (fs.existsSync(filePath)) {
+                try {
+                    const serialized = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+                    return this.ig.state.deserialize(serialized).then(() => {
+                        logger.info(`successfully loaded client session cookies for user ${this.username}`);
+                    });
+                }
+                catch (err) {
+                    logger.error(`failed to load client session cookies from file ${this.lockfile}: `, err);
+                    return Promise.resolve();
+                }
+            }
+            else
+                return this.login();
+        };
+        this.login = () => this.ig.simulate.preLoginFlow()
+            .then(() => this.ig.account.login(this.username, this.password))
+            .then(() => new Promise(resolve => {
+            logger.info(`successfully logged in as ${this.username}`);
+            process.nextTick(() => resolve(this.ig.simulate.postLoginFlow()));
+        }));
+        this.save = () => this.ig.state.serialize()
+            .then((serialized) => {
+            delete serialized.constants;
+            return fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(serialized, null, 2), 'utf-8');
+        });
+        this.ig = client;
+        this.lockfile = file;
+        [this.username, this.password] = credentials;
+    }
+}
+exports.SessionManager = SessionManager;
 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;
+                    if (!(err instanceof instagram_private_api_1.IgExactUserNotFoundError)) {
+                        logger.warn(`error looking up user: ${err.message}`);
+                        return `${username}:`;
                     }
                     return null;
                 });
@@ -35,19 +97,16 @@ class ScreenNameNormalizer {
     }
 }
 exports.ScreenNameNormalizer = ScreenNameNormalizer;
-ScreenNameNormalizer.normalize = (username) => username.toLowerCase().replace(/^@/, '');
-let sendTweet = (id, receiver) => {
+ScreenNameNormalizer.normalize = (username) => `${username.toLowerCase().replace(/^@/, '')}:`;
+let browserLogin = (page) => Promise.reject();
+exports.browserLogin = browserLogin;
+let getPostOwner = (segmentId) => Promise.reject();
+exports.getPostOwner = getPostOwner;
+let sendPost = (segmentId, receiver) => {
     throw Error();
 };
-exports.sendTweet = sendTweet;
-let sendTimeline = (conf, receiver) => {
-    throw Error();
-};
-exports.sendTimeline = sendTimeline;
-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');
+exports.sendPost = sendPost;
+const logger = loggers_1.getLogger('instagram');
 const maxTrials = 3;
 const retryInterval = 1500;
 const ordinal = (n) => {
@@ -76,71 +135,19 @@ const retryOnError = (doWork, onRetry) => new Promise(resolve => {
 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) => {
-                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}`);
-                    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);
-                }
-                return fetchTimeline(config, tweets);
-            });
-            return fetchTimeline();
-        };
-        this.workOnTweets = (tweets, sendTweets) => this.webshot(tweets, sendTweets, this.webshotDelay);
-        this.getTweet = (id, sender) => {
-            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);
-            });
+            this.webshot = new webshot_1.default(this.wsUrl, this.mode, () => this.webshotCookies, () => setTimeout(this.work, this.workInterval * 1000));
         };
-        this.sendTweets = (source, ...to) => (msg, text, author) => {
+        this.queryUser = (username) => this.client.user.searchExact(username)
+            .then(user => `${user.username}:${user.pk}`);
+        this.workOnMedia = (mediaItems, sendMedia) => this.webshot(mediaItems, sendMedia, this.webshotDelay);
+        this.urlSegmentToId = instagram_id_to_url_segment_1.urlSegmentToInstagramId;
+        this.getMedia = (segmentId, sender) => this.client.media.info(instagram_id_to_url_segment_1.urlSegmentToInstagramId(segmentId))
+            .then(media => {
+            const mediaItem = media.items[0];
+            logger.debug(`api returned media post ${JSON.stringify(mediaItem)} for query id=${segmentId}`);
+            return this.workOnMedia([mediaItem], sender);
+        });
+        this.sendMedia = (source, ...to) => (msg, text, author) => {
             to.forEach(subscriber => {
                 logger.info(`pushing data${source ? ` of ${koishi_1.Message.ellipseBase64(source)}` : ''} to ${JSON.stringify(subscriber)}`);
                 retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
@@ -180,76 +187,55 @@ class default_1 {
             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;
+                const match = /https:\/\/www\.instagram\.com\/([^\/]+)/.exec(currentFeed);
                 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;
-                    this.client.get(endpoint, config, (error, tweets, response) => {
-                        if (error) {
-                            if (error instanceof Array && error.length > 0 && error[0].code === 34) {
-                                logger.warn(`error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
+                    const feed = this.client.feed.user(lock.threads[currentFeed].id);
+                    const newer = (item) => utils_1.BigNumOps.compare(item.pk, lock.threads[currentFeed].offset) > 0;
+                    const fetchMore = () => new Promise(fetch => {
+                        feed.request().then(response => {
+                            if (response.items.length === 0)
+                                return fetch([]);
+                            if (response.items.every(newer)) {
+                                fetchMore().then(fetched => fetch(response.items.concat(fetched)));
+                            }
+                            else
+                                fetch(response.items.filter(newer));
+                        }, (error) => {
+                            if (error instanceof instagram_private_api_1.IgNetworkError) {
+                                logger.warn(`error on fetching media for ${currentFeed}: ${JSON.stringify(error.cause)}`);
+                                if (!(error instanceof instagram_private_api_1.IgNotFoundError))
+                                    return;
                                 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)}`);
+                                logger.error(`unhandled error on fetching media for ${currentFeed}: ${JSON.stringify(error)}`);
                             }
-                            resolve([]);
-                        }
-                        else
-                            resolve(tweets);
+                            fetch([]);
+                        });
                     });
+                    fetchMore().then(resolve);
                 }
             });
-            promise.then((tweets) => {
-                logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
+            promise.then((mediaItems) => {
+                logger.debug(`api returned ${JSON.stringify(mediaItems)} for feed ${currentFeed}`);
                 const currentThread = lock.threads[currentFeed];
                 const updateDate = () => currentThread.updatedAt = new Date().toString();
-                if (!tweets || tweets.length === 0) {
+                if (!mediaItems || mediaItems.length === 0) {
                     updateDate();
                     return;
                 }
-                const topOfFeed = tweets[0].id_str;
+                const topOfFeed = mediaItems[0].pk;
                 const updateOffset = () => currentThread.offset = topOfFeed;
                 if (currentThread.offset === '-1') {
                     updateOffset();
                     return;
                 }
                 if (currentThread.offset === '0')
-                    tweets.splice(1);
-                return this.workOnTweets(tweets, this.sendTweets(`thread ${currentFeed}`, ...currentThread.subscribers))
+                    mediaItems.splice(1);
+                return this.workOnMedia(mediaItems, this.sendMedia(`thread ${currentFeed}`, ...currentThread.subscribers))
                     .then(updateDate).then(updateOffset);
             })
                 .then(() => {
@@ -263,57 +249,62 @@ class default_1 {
                 }, timeout);
             });
         };
-        this.client = new Twitter({
-            consumer_key: opt.consumerKey,
-            consumer_secret: opt.consumerSecret,
-            access_token_key: opt.accessTokenKey,
-            access_token_secret: opt.accessTokenSecret,
-        });
+        this.client = new instagram_private_api_1.IgApiClient();
+        this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
         this.lockfile = opt.lockfile;
+        this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
         this.lock = opt.lock;
         this.workInterval = opt.workInterval;
         this.bot = opt.bot;
         this.webshotDelay = opt.webshotDelay;
         this.mode = opt.mode;
         this.wsUrl = opt.wsUrl;
-        ScreenNameNormalizer._queryUser = this.queryUser;
-        exports.sendTweet = (id, receiver) => {
-            this.getTweet(id, this.sendTweets(`tweet ${id}`, receiver))
-                .catch((err) => {
-                if (err[0].code !== 144) {
-                    logger.warn(`error retrieving tweet: ${err[0].message}`);
-                    this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
-                }
-                this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
-            });
-        };
-        exports.sendTimeline = ({ username, count, since, until, noreps, norts }, receiver) => {
-            const countNum = Number(count) || 10;
-            (countNum > 0 ? this.queryTimeline : this.queryTimelineReverse)({
-                username,
-                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],
+        const cookiesFilePath = path.resolve(this.webshotCookiesLockfile);
+        if (fs.existsSync(cookiesFilePath)) {
+            try {
+                this.webshotCookies = JSON.parse(fs.readFileSync(cookiesFilePath, 'utf8'));
+                logger.info(`loaded webshot cookies from file ${this.webshotCookiesLockfile}`);
+            }
+            catch (err) {
+                logger.warn(`failed to load webshot cookies from file ${this.webshotCookiesLockfile}: `, err);
+                logger.warn('cookies will be saved to this file when needed');
+            }
+        }
+        exports.browserLogin = (page) => {
+            logger.warn('blocked by login dialog, trying to log in manually...');
+            return page.type('input[name="username"]', opt.credentials[0])
+                .then(() => page.type('input[name="password"]', opt.credentials[1]))
+                .then(() => page.click('button[type="submit"]'))
+                .then(() => page.click('button:has-text("情報を保存")'))
+                .then(() => page.waitForSelector('img[data-testid="user-avatar"]', { timeout: this.webshotDelay }))
+                .then(() => page.context().cookies())
+                .then(cookies => {
+                this.webshotCookies = cookies;
+                logger.info('successfully logged in, saving cookies to file...');
+                fs.writeFileSync(path.resolve(this.webshotCookiesLockfile), JSON.stringify(cookies, null, 2), 'utf-8');
             })
-                .then(tweets => utils_1.chainPromises(tweets.map(tweet => this.bot.sendTo(receiver, `\
-编号:${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 <编号> 查看推文详细内容。' :
-                '时间线查询完毕,没有找到符合条件的推文。'))))
                 .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')}。`);
+                if (err.name === 'TimeoutError')
+                    logger.warn('navigation timed out, assuming login has failed');
+                throw err;
             });
         };
+        ScreenNameNormalizer._queryUser = this.queryUser;
+        const parseMediaError = (err) => {
+            if (!(err instanceof instagram_private_api_1.IgResponseError && err.text === 'Media not found or unavailable')) {
+                logger.warn(`error retrieving instagram media: ${err.message}`);
+                return `获取媒体时出现错误:${err.message}`;
+            }
+            return '找不到请求的媒体,它可能已被删除。';
+        };
+        exports.getPostOwner = (segmentId) => this.client.media.info(instagram_id_to_url_segment_1.urlSegmentToInstagramId(segmentId))
+            .then(media => media.items[0].user)
+            .then(user => `${user.username}:${user.pk}`)
+            .catch((err) => { throw Error(parseMediaError(err)); });
+        exports.sendPost = (segmentId, receiver) => {
+            this.getMedia(segmentId, this.sendMedia(`instagram media ${segmentId}`, receiver))
+                .catch((err) => { this.bot.sendTo(receiver, parseMediaError(err)); });
+        };
     }
 }
 exports.default = default_1;

+ 66 - 153
dist/webshot.js

@@ -12,6 +12,7 @@ const temp = require("temp");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
 const utils_1 = require("./utils");
+const twitter_1 = require("./twitter");
 const xmlEntities = new html_entities_1.XmlEntities();
 const ZHType = (type) => new class extends String {
     constructor() {
@@ -23,11 +24,10 @@ const ZHType = (type) => new class extends String {
 const typeInZH = {
     photo: ZHType('图片'),
     video: ZHType('视频'),
-    animated_gif: ZHType('GIF'),
 };
 const logger = loggers_1.getLogger('webshot');
 class Webshot extends CallableInstance {
-    constructor(wsUrl, mode, onready) {
+    constructor(wsUrl, mode, getCookies, onready) {
         super('webshot');
         this.connect = (onready) => axios_1.default.get(this.wsUrl)
             .then(res => {
@@ -49,9 +49,6 @@ class Webshot extends CallableInstance {
             return util_1.promisify(setTimeout)(2500)
                 .then(() => this.connect(onready));
         };
-        this.extendEntity = (media) => {
-            logger.info('not working on a tweet');
-        };
         this.renderWebshot = (url, height, webshotDelay) => {
             temp.track();
             const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
@@ -77,63 +74,28 @@ class Webshot extends CallableInstance {
                     page.setViewportSize({
                         width: width / zoomFactor,
                         height: height / zoomFactor,
-                    })
+                    }).then(() => page.context().addCookies(this.getCookies()))
                         .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                        .then(() => page.addStyleTag({
-                        content: 'header{display:none!important}[data-testid="caret"],[data-testid="tweet"]+*>:nth-last-child(-n+2){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) {
-                                    container.innerHTML = container.innerHTML;
-                                    clearInterval(poll);
-                                }
-                            });
-                        }, 250);
-                    }))
-                        .then(() => page.waitForSelector('article', { timeout: getTimeout() }))
+                        .then(() => ((next) => Promise.race([
+                        page.click('button:has-text("すべて許可")').then(() => twitter_1.browserLogin(page))
+                            .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
+                            .then(() => next),
+                        page.click('button:has-text("すべて許可")').then(() => next),
+                        next,
+                    ]))(page.waitForSelector('article', { timeout: getTimeout() })))
                         .catch((err) => {
                         if (err.name !== 'TimeoutError')
                             throw err;
-                        logger.warn(`navigation timed out at ${getTimerTime()} seconds`);
+                        logger.warn(`navigation timed out at ${getTimerTime()} ms`);
                         return null;
                     })
-                        .then(handle => {
-                        if (handle === null)
-                            throw new puppeteer.errors.TimeoutError();
-                    })
-                        .then(() => page.evaluate(() => {
-                        const cardImg = document.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(() => page.addStyleTag({ content: 'nav,footer,header+div,header+div+div>div>div+div,header div div div+div,' +
+                            'article section,article section+div>ul>:not(div),article section+div~div,article button,canvas{display:none!important} ' +
+                            'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important}',
                     }))
-                        .then(cardImg => {
-                        if (cardImg)
-                            this.extendEntity(cardImg);
-                    })
-                        .then(() => page.addScriptTag({
-                        content: 'document.documentElement.scrollTop=0;',
+                        .then(() => page.addStyleTag({
+                        content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
                     }))
-                        .then(() => util_1.promisify(setTimeout)(getTimeout()))
                         .then(() => page.screenshot())
                         .then(screenshot => {
                         new pngjs_1.PNG({
@@ -142,16 +104,11 @@ class Webshot extends CallableInstance {
                         }).on('parsed', function () {
                             const idx = (x, y) => (this.width * y + x) << 2;
                             let boundary = null;
-                            const x = zoomFactor * 2;
-                            for (let y = 0; y < this.height; y += zoomFactor) {
-                                if (this.data[idx(x, y)] !== 255 &&
-                                    this.data[idx(x, y)] === this.data[idx(x + zoomFactor * 10, y)]) {
-                                    if (this.data[idx(x, y + 18 * zoomFactor)] !== 255) {
-                                        boundary = null;
-                                    }
-                                    else {
-                                        boundary = y;
-                                    }
+                            for (let y = this.height - 1; y > this.height - 1920; y -= zoomFactor) {
+                                if (this.data[idx(zoomFactor, y)] <= 38 &&
+                                    this.data[idx(zoomFactor, y)] === this.data[idx(this.width - zoomFactor, y)] &&
+                                    this.data[idx(zoomFactor, y + zoomFactor)] === this.data[idx(zoomFactor, y - 2 * zoomFactor)]) {
+                                    boundary = y;
                                     break;
                                 }
                             }
@@ -214,68 +171,44 @@ class Webshot extends CallableInstance {
                 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]);
-        });
+        }).then(data => (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();
+        })(/\/.*\.(.+?)\?/.exec(url)[1]));
         if (this.mode = mode) {
             onready();
         }
         else {
+            this.getCookies = getCookies;
             this.wsUrl = wsUrl;
             this.connect(onready);
         }
     }
-    webshot(tweets, callback, webshotDelay) {
+    webshot(mediaItems, callback, webshotDelay) {
         let promise = new Promise(resolve => {
             resolve();
         });
-        tweets.forEach(twi => {
+        mediaItems.forEach(item => {
             promise = promise.then(() => {
-                logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
+                logger.info(`working on ${item.user.username}/${item.code}`);
             });
-            const originTwi = twi.retweeted_status || twi;
             let messageChain = '';
-            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));
-            });
+            const author = `${item.user.full_name} (@${item.user.username}):\n`;
+            const text = item.caption.text;
+            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,
-                        ] });
-                };
+                const url = twitter_1.linkBuilder({ postUrlSegment: item.code });
                 promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
                     .then(fileurl => {
                     if (fileurl)
@@ -287,53 +220,33 @@ class Webshot extends CallableInstance {
                         messageChain += msg;
                 });
             }
+            const mediaType = item.video_versions ? 'video' : 'photo';
+            const fetchBestCandidate = (candidates) => {
+                const url = candidates
+                    .sort((var1, var2) => var2.width - var1.width)
+                    .map(variant => variant.url)[0];
+                const altMessage = `\n[失败的${typeInZH[mediaType].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 (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 (item.carousel_media) {
+                        return utils_1.chainPromises(item.carousel_media.map(carouselItem => fetchBestCandidate(carouselItem.image_versions2.candidates)));
+                    }
+                    else if (item.video_versions) {
+                        return fetchBestCandidate(item.video_versions);
+                    }
+                    else if (item.image_versions2) {
+                        return fetchBestCandidate(item.image_versions2.candidates);
                     }
                 });
-            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('');
-                        }
-                    });
-                }
-            }
-            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(`done working on ${item.user.username}/${item.code}, message chain:`);
                 logger.info(JSON.stringify(koishi_1.Message.ellipseBase64(messageChain)));
                 callback(messageChain, xmlEntities.decode(text), author);
             });

+ 3 - 4
package.json

@@ -33,6 +33,8 @@
     "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
     "html-entities": "^1.3.1",
+    "instagram-id-to-url-segment": "github:CL-Jeremy/instagram-id-to-url-segment#built",
+    "instagram-private-api": "^1.44.1",
     "koishi": "^3.10.0",
     "koishi-adapter-onebot": "^3.0.8",
     "log4js": "^6.3.0",
@@ -42,7 +44,6 @@
     "sha1": "^1.1.1",
     "sharp": "^0.25.4",
     "temp": "^0.9.1",
-    "twitter": "^1.7.1",
     "typescript": "^4.2.3"
   },
   "devDependencies": {
@@ -53,7 +54,6 @@
     "@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",
     "eslint": "^7.25.0",
@@ -61,7 +61,6 @@
     "eslint-plugin-jsdoc": "^32.3.1",
     "eslint-plugin-prefer-arrow": "^1.2.3",
     "eslint-plugin-react": "^7.23.2",
-    "tslint-config-prettier": "^1.13.0",
-    "twitter-d": "^0.4.0"
+    "tslint-config-prettier": "^1.13.0"
   }
 }

+ 43 - 104
src/command.ts

@@ -6,7 +6,10 @@ import * as path from 'path';
 
 import { relativeDate } from './datetime';
 import { getLogger } from './loggers';
-import { sendTimeline, sendTweet, ScreenNameNormalizer as normalizer } from './twitter';
+import {
+  getPostOwner, sendPost, ScreenNameNormalizer as normalizer,
+  isValidUrlSegment, linkBuilder, parseLink, urlSegmentToId
+} from './twitter';
 import { BigNumOps } from './utils';
 
 const logger = getLogger('command');
@@ -34,29 +37,8 @@ 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());
+function linkFinder(userName: string, chat: IChat, lock: ILock): [string, number] {
+  const normalizedLink = linkBuilder({userName});
   const link = Object.keys(lock.threads).find(realLink =>
     normalizedLink === realLink.replace(/\/@/, '/').toLowerCase()
   );
@@ -76,27 +58,20 @@ function sub(chat: IChat, args: string[], reply: (msg: string) => any,
   if (args.length === 0) {
     return reply('找不到要订阅的链接。');
   }
-  const match = parseLink(args[0]);
-  if (!match) {
+  const matched = parseLink(args[0]);
+  if (!matched) {
     return reply(`订阅链接格式错误:
 示例:
-https://twitter.com/Saito_Shuka
-https://twitter.com/rikakomoe/lists/lovelive
-https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
+https://www.instagram.com/tomoyo_kurosawa_/
+https://www.instagram.com/p/B6GHRSmgV-7/`);
   }
   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) {
+  const subscribeTo = (link: string, config: {id?: number, msg?: string} = {}) => {
+    const {id, msg = `已为此聊天订阅 ${link}`} = config;
+    if (id) {
       lock.feed.push(link);
       lock.threads[link] = {
+        id,
         offset,
         subscribers: [],
         updatedAt: '',
@@ -107,22 +82,30 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
     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});
+  const tryFindSub = (userName: string) => {
+    const [realLink, index] = linkFinder(userName, chat, lock);
+    if (index > -1) { reply('此聊天已订阅此链接。'); return true; }
+    if (realLink) { subscribeTo(realLink); return true; }
+    return false;
+  };
+  const newSub = (userName: string) => {
+    const link = linkBuilder(matched);
+    subscribeTo(link, {id: Number(userName.split(':')[1])});
+  };
+  if (matched.postUrlSegment) {
+    offset = BigNumOps.plus(urlSegmentToId(matched.postUrlSegment), '-1');
+    delete matched.postUrlSegment;
+    getPostOwner(matched.postUrlSegment).then(userName => {
+      if (!tryFindSub(userName)) newSub(userName);
+    }).catch((parsedErr: Error) => {
+      reply(parsedErr.message);
+    });
+  } else if (!tryFindSub(matched.userName)) {
+    normalizer.normalizeLive(matched.userName).then(userName => {
+      if (!userName) return reply(`找不到用户 ${matched.userName.replace(/^@?(.*)$/, '@$1')}。`);
+      else newSub(userName);
+    });
   }
-  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 unsub(chat: IChat, args: string[], reply: (msg: string) => any,
@@ -134,7 +117,7 @@ function unsub(chat: IChat, args: string[], reply: (msg: string) => any,
   if (args.length === 0) {
     return reply('找不到要退订的链接。');
   }
-  const match = parseLink(args[0]);
+  const match = parseLink(args[0])?.userName;
   if (!match) {
     return reply('链接格式有误。');
   }
@@ -158,66 +141,22 @@ function list(chat: IChat, _: string[], reply: (msg: string) => any, lock: ILock
       chat.chatID === chatID && chat.chatType === chatType
     )) links.push(`${key} ${relativeDate(lock.threads[key].updatedAt)}`);
   });
-  return reply('此聊天中订阅的链接:\n' + links.join('\n'));
+  return reply('此聊天中订阅的 Instagram 动态链接:\n' + links.join('\n'));
 }
 
 function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
   if (args.length === 0) {
     return reply('找不到要查看的链接。');
   }
-  const match = /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
+  const match = isValidUrlSegment(args[0]) && args[0] || parseLink(args[0])?.postUrlSegment;
   if (!match) {
     return reply('链接格式有误。');
   }
   try {
-    sendTweet(match[1], chat);
-  } catch (e) {
-    reply('推特机器人尚未加载完毕,请稍后重试。');
-  }
-}
-
-function query(chat: IChat, args: string[], reply: (msg: string) => any): void {
-  if (args.length === 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> = {
-    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);
-    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('查询数量上限参数为零、非数值或超出取值范围。');
-  }
-  try {
-    sendTimeline(conf, chat);
+    sendPost(match, chat);
   } catch (e) {
-    logger.error(`error querying timeline, error: ${e}`);
-    reply('推特机器人尚未加载完毕,请稍后重试。');
+    reply('机器人尚未加载完毕,请稍后重试。');
   }
 }
 
-export { parseCmd, sub, list, unsub, view, query };
+export { parseCmd, sub, list, unsub, view };

+ 13 - 38
src/koishi.ts

@@ -1,7 +1,7 @@
 import { App, Bot, segment, Session, sleep } from 'koishi';
 import 'koishi-adapter-onebot';
 
-import { parseCmd, query, view } from './command';
+import { parseCmd, view } from './command';
 import { getLogger } from './loggers';
 import { chainPromises } from './utils';
 
@@ -166,57 +166,32 @@ export default class {
       const cmdObj = parseCmd(session.content);
       const reply = async msg => session.sendQueued(msg);
       switch (cmdObj.cmd) {
-        case 'twitter_view':
-        case 'twitter_get':
+        case 'instagram_view':
+        case 'instagram_get':
           view(chat, cmdObj.args, reply);
           break;
-        case 'twitter_query':
-        case 'twitter_gettimeline':
-          query(chat, cmdObj.args, reply);
-          break;
-        case 'twitter_sub':
-        case 'twitter_subscribe':
+        case 'instagram_sub':
+        case 'instagram_subscribe':
           this.botInfo.sub(chat, cmdObj.args, reply);
           break;
-        case 'twitter_unsub':
-        case 'twitter_unsubscribe':
+        case 'instagram_unsub':
+        case 'instagram_unsubscribe':
           this.botInfo.unsub(chat, cmdObj.args, reply);
           break;
         case 'ping':
-        case 'twitter':
+        case 'instagram':
           this.botInfo.list(chat, cmdObj.args, reply);
           break;
         case 'help':
           if (cmdObj.args.length === 0) {
-            reply(`推特搬运机器人:
-/twitter - 查询当前聊天中的推文订阅
-/twitter_subscribe〈链接|用户名〉- 订阅 Twitter 推文搬运
-/twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
-/twitter_view〈链接〉- 查看推文
-/twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
+            reply(`Instagram 搬运机器人:
+/instagram - 查询当前聊天中的 Instagram 动态订阅
+/instagram_subscribe〈链接|用户名〉- 订阅 Instagram 媒体搬运
+/instagram_unsubscribe〈链接|用户名〉- 退订 Instagram 媒体搬运
+/instagram_view〈链接〉- 查看媒体
 ${chat.chatType === ChatType.Temp ?
     '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
 }`);
-          } else if (cmdObj.args[0] === 'twitter_query') {
-            reply(`查询时间线中的推文:
-/twitter_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
-
-参数列表(方框内全部为可选,留空则为默认):
-    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
-    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
-    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
-    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
-    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
-              .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 条)`)
-              );
           }
       }
     }, true);

+ 17 - 11
src/main.ts

@@ -15,14 +15,14 @@ const logger = getLogger();
 
 const sections: commandLineUsage.Section[] = [
   {
-    header: 'MiraiTS Twitter Bot',
-    content: 'The QQ Bot that forwards twitters.',
+    header: 'GoCQHTTP Instagram Bot',
+    content: 'The QQ Bot that forwards Instagram.',
   },
   {
     header: 'Synopsis',
     content: [
-      '$ mirai-twitter-bot {underline config.json}',
-      '$ mirai-twitter-bot {bold --help}',
+      '$ cq-instagram-bot {underline config.json}',
+      '$ cq-instagram-bot {bold --help}',
     ],
   },
   {
@@ -56,7 +56,7 @@ try {
 }
 
 const requiredFields = [
-  'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+  'ig_username', 'ig_password',
   'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
 ];
 
@@ -75,11 +75,18 @@ if (requiredFields.some((value) => config[value] === undefined)) {
 
 optionalFields.forEach(key => {
   if (config[key] === undefined || typeof(config[key]) !== typeof (exampleConfig[key])) {
-    if (key in warningFields) logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+    if (warningFields.includes(key)) logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
     config[key] = exampleConfig[key as keyof Config];
   }
 });
 
+['ig_session_lockfile', ...(config.mode || exampleConfig.mode) === 0 ? ['webshot_cookies_lockfile'] : []].forEach(key => {
+  if (!config[key]) {
+    logger.warn(`${key} is undefined, use <username>.${key.replace('_lockfile', '.lock')} as default`);
+    config[key] = `${config.ig_username}.${key.replace('_lockfile', '.lock')}`;
+  }
+});
+
 setLogLevels(config.loglevel);
 
 let lock: ILock;
@@ -131,18 +138,17 @@ const qq = new QQBot({
 });
 
 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,
+  sessionLockfile: config.ig_session_lockfile,
+  credentials: [config.ig_username, config.ig_password],
   lock,
   lockfile: config.lockfile,
   workInterval: config.work_interval,
   bot: qq,
   webshotDelay: config.webshot_delay,
+  webshotCookiesLockfile: config.webshot_cookies_lockfile,
   mode: config.mode,
   wsUrl: config.playwright_ws_spec_endpoint,
 });
-worker.launch();
+worker.session.init().then(worker.launch);
 
 qq.connect();

+ 1 - 0
src/model.d.ts

@@ -27,6 +27,7 @@ interface ILock {
   threads: {
     [key: string]:
     {
+      id: number,
       offset: string,
       updatedAt: string,
       subscribers: IChat[],

+ 182 - 218
src/twitter.ts

@@ -1,41 +1,113 @@
 import * as fs from 'fs';
 import * as path from 'path';
-import * as Twitter from 'twitter';
-import TwitterTypes from 'twitter-d';
+import {
+  instagramIdToUrlSegment as idToUrlSegment,
+  urlSegmentToInstagramId as urlSegmentToId
+} from 'instagram-id-to-url-segment';
+import {
+  IgApiClient,
+  IgClientError, IgExactUserNotFoundError, IgNetworkError, IgNotFoundError, IgResponseError,
+  MediaInfoResponseItemsItem, UserFeedResponseItemsItem
+} from 'instagram-private-api';
+import { RequestError } from 'request-promise/errors';
 
 import { getLogger } from './loggers';
 import QQBot, { Message } from './koishi';
-import { chainPromises, BigNumOps } from './utils';
-import Webshot from './webshot';
+import { BigNumOps } from './utils';
+import Webshot, { Cookies, Page } from './webshot';
+
+const parseLink = (link: string): {userName?: string, postUrlSegment?: string} => {
+  let match =
+    /instagram\.com\/p\/([A-Za-z0-9\-_]+)/.exec(link);
+  if (match) return {postUrlSegment: match[1]};
+  match =
+    /instagram\.com\/([^\/?#]+)/.exec(link) ||
+    /^([^\/?#]+)$/.exec(link);
+  if (match) return {userName: ScreenNameNormalizer.normalize(match[1]).split(':')[0]};
+  return;
+};
+
+const isValidUrlSegment = (input: string) => /^[A-Za-z0-9\-_]+$/.test(input);
+
+const linkBuilder = (config: ReturnType<typeof parseLink>): string => {
+  if (config.userName) return `https://www.instagram.com/${config.userName}/`;
+  if (config.postUrlSegment) return `https://www.instagram.com/p/${config.postUrlSegment}/`;
+};
+
+export {linkBuilder, parseLink, isValidUrlSegment, idToUrlSegment, urlSegmentToId};
 
 interface IWorkerOption {
+  sessionLockfile: string;
+  credentials: [string, string];
   lock: ILock;
   lockfile: string;
+  webshotCookiesLockfile: string;
   bot: QQBot;
   workInterval: number;
   webshotDelay: number;
-  consumerKey: string;
-  consumerSecret: string;
-  accessTokenKey: string;
-  accessTokenSecret: string;
   mode: number;
   wsUrl: string;
 }
 
+export class SessionManager {
+  private ig: IgApiClient;
+  private username: string;
+  private password: string;
+  private lockfile: string;
+  
+  constructor(client: IgApiClient, file: string, credentials: [string, string]) {
+    this.ig = client;
+    this.lockfile = file;
+    [this.username, this.password] = credentials;
+  }
+
+  public init = () => {
+    this.ig.state.generateDevice(this.username);
+    this.ig.request.end$.subscribe(() => { this.save(); });
+    const filePath = path.resolve(this.lockfile);
+    if (fs.existsSync(filePath)) {
+      try {
+        const serialized = JSON.parse(fs.readFileSync(filePath, 'utf8')) as {[key: string]: any};
+        return this.ig.state.deserialize(serialized).then(() => { 
+          logger.info(`successfully loaded client session cookies for user ${this.username}`);
+        });
+      } catch (err) {
+        logger.error(`failed to load client session cookies from file ${this.lockfile}: `, err);
+        return Promise.resolve();
+      }
+    } else return this.login();
+  };
+
+  public login = () =>
+    this.ig.simulate.preLoginFlow()
+      .then(() => this.ig.account.login(this.username, this.password))
+      .then(() => new Promise(resolve => {
+        logger.info(`successfully logged in as ${this.username}`);
+        process.nextTick(() => resolve(this.ig.simulate.postLoginFlow()));
+      }));
+
+  public save = () =>
+    this.ig.state.serialize()
+      .then((serialized: {[key: string]: any}) => {
+        delete serialized.constants;
+        return fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(serialized, null, 2), 'utf-8');
+      });
+}
+
 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 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;
+        .catch((err: IgClientError) => {
+          if (!(err instanceof IgExactUserNotFoundError)) {
+            logger.warn(`error looking up user: ${err.message}`);
+            return `${username}:`;
           }
           return null;
         });
@@ -44,31 +116,17 @@ export class ScreenNameNormalizer {
   }
 }
 
-export let sendTweet = (id: string, receiver: IChat): void => {
-  throw Error();
-};
+export let browserLogin = (page: Page): Promise<void> => Promise.reject();
 
-export interface ITimelineQueryConfig {
-  username: string;
-  count?: number;
-  since?: string;
-  until?: string;
-  noreps?: boolean;
-  norts?: boolean;
-}
+export let getPostOwner = (segmentId: string): Promise<string> => Promise.reject();
 
-export let sendTimeline = (
-  conf: {[key in keyof ITimelineQueryConfig]: string},
-  receiver: IChat
-): void => {
+export let sendPost = (segmentId: string, receiver: IChat): void => {
   throw Error();
 };
 
-const TWITTER_EPOCH = 1288834974657;
-const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
-  BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
+export type MediaItem = MediaInfoResponseItemsItem & UserFeedResponseItemsItem;
 
-const logger = getLogger('twitter');
+const logger = getLogger('instagram');
 const maxTrials = 3;
 const retryInterval = 1500;
 const ordinal = (n: number) => {
@@ -97,85 +155,79 @@ const retryOnError = <T, U>(
   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 {
-  user: FullUser;
-  retweeted_status?: Tweet;
-}
-
-export type Tweet = ITweet;
-export type Tweets = ITweet[];
-
 export default class {
 
-  private client: Twitter;
+  private client: IgApiClient;
   private lock: ILock;
   private lockfile: string;
   private workInterval: number;
   private bot: QQBot;
   private webshotDelay: number;
+  private webshotCookies: Cookies;
+  private webshotCookiesLockfile: string;
   private webshot: Webshot;
   private mode: number;
   private wsUrl: string;
 
+  public session: SessionManager;
+
   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.client = new IgApiClient();
+    this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
     this.lockfile = opt.lockfile;
+    this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
     this.lock = opt.lock;
     this.workInterval = opt.workInterval;
     this.bot = opt.bot;
     this.webshotDelay = opt.webshotDelay;
     this.mode = opt.mode;
     this.wsUrl = opt.wsUrl;
-    ScreenNameNormalizer._queryUser = this.queryUser;
-    sendTweet = (id, receiver) => {
-      this.getTweet(id, this.sendTweets(`tweet ${id}`, receiver))
-        .catch((err: {code: number, message: string}[]) => {
-          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 cookiesFilePath = path.resolve(this.webshotCookiesLockfile);
+    if (fs.existsSync(cookiesFilePath)) {
+      try {
+        this.webshotCookies = JSON.parse(fs.readFileSync(cookiesFilePath, 'utf8')) as Cookies;
+        logger.info(`loaded webshot cookies from file ${this.webshotCookiesLockfile}`);
+      } catch (err) {
+        logger.warn(`failed to load webshot cookies from file ${this.webshotCookiesLockfile}: `, err);
+        logger.warn('cookies will be saved to this file when needed');
+      }
+    }
+
+    browserLogin = (page) => {
+      logger.warn('blocked by login dialog, trying to log in manually...');
+      return page.type('input[name="username"]', opt.credentials[0])
+        .then(() => page.type('input[name="password"]', opt.credentials[1]))
+        .then(() => page.click('button[type="submit"]'))
+        .then(() => page.click('button:has-text("情報を保存")'))
+        .then(() => page.waitForSelector('img[data-testid="user-avatar"]', {timeout: this.webshotDelay}))
+        .then(() => page.context().cookies())
+        .then(cookies => {
+          this.webshotCookies = cookies;
+          logger.info('successfully logged in, saving cookies to file...');
+          fs.writeFileSync(path.resolve(this.webshotCookiesLockfile), JSON.stringify(cookies, null, 2), 'utf-8');
+        })
+        .catch((err: Error) => {
+          if (err.name === 'TimeoutError') logger.warn('navigation timed out, assuming login has failed');
+          throw err;
         });
     };
-    sendTimeline = ({username, count, since, until, noreps, norts}, receiver) => {
-      const countNum = Number(count) || 10;
-      (countNum > 0 ? this.queryTimeline : this.queryTimelineReverse)({
-        username,
-        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.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 <编号> 查看推文详细内容。' :
-              '时间线查询完毕,没有找到符合条件的推文。'
-            ))
-        ))
-        .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')}。`);
-        });
+    ScreenNameNormalizer._queryUser = this.queryUser;
+    const parseMediaError = (err: IgClientError) => {
+      if (!(err instanceof IgResponseError && err.text === 'Media not found or unavailable')) {
+        logger.warn(`error retrieving instagram media: ${err.message}`);
+        return `获取媒体时出现错误:${err.message}`;
+      }
+      return '找不到请求的媒体,它可能已被删除。';
+    };
+    getPostOwner = (segmentId) => 
+      this.client.media.info(urlSegmentToId(segmentId))
+        .then(media => media.items[0].user)
+        .then(user => `${user.username}:${user.pk}`)
+        .catch((err: IgClientError) => { throw Error(parseMediaError(err)); });
+    sendPost = (segmentId, receiver) => {
+      this.getMedia(segmentId, this.sendMedia(`instagram media ${segmentId}`, receiver))
+        .catch((err: IgClientError) => { this.bot.sendTo(receiver, parseMediaError(err)); });
     };
   }
 
@@ -183,98 +235,30 @@ export default class {
     this.webshot = new Webshot(
       this.wsUrl,
       this.mode,
+      () => this.webshotCookies,
       () => 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 queryUser = (username: string) => this.client.user.searchExact(username)
+    .then(user => `${user.username}:${user.pk}`);
 
-  public queryTimeline = (
-    { username, count, since, until, noreps, norts }: ITimelineQueryConfig
-  ) => {
-    username = username.replace(/^@?(.*)$/, '@$1');
-    logger.info(`querying timeline of ${username} with config: ${
-      JSON.stringify({
-        ...(count && {count}),
-        ...(since && {since}),
-        ...(until && {until}),
-        ...(noreps && {noreps}),
-        ...(norts && {norts}),
-      })}`);
-    const fetchTimeline = (
-      config = {
-        screen_name: username.slice(1),
-        trim_user: true,
-        exclude_replies: noreps ?? true,
-        include_rts: !(norts ?? false),
-        since_id: since,
-        max_id: until,
-        tweet_mode: 'extended',
-      },
-      tweets: ITweet[] = []
-    ): Promise<ITweet[]> => this.client.get('statuses/user_timeline', config)
-      .then((newTweets: ITweet[]) => {
-        if (newTweets.length) {
-          logger.debug(`fetched tweets: ${JSON.stringify(newTweets)}`);
-          config.max_id = BigNumOps.plus('-1', newTweets[newTweets.length - 1].id_str);
-          logger.info(`timeline query of ${username} yielded ${
-            newTweets.length
-          } new tweets, next query will start at offset ${config.max_id}`);
-          tweets.push(...newTweets);
-        }
-        if (!newTweets.length || tweets.length >= count) {
-          logger.info(`timeline query of ${username} finished successfully, ${
-            tweets.length
-          } tweets have been fetched`);
-          return tweets.slice(0, count);
-        }
-        return fetchTimeline(config, tweets);
-      });
-    return fetchTimeline();
-  };
+  private workOnMedia = (
+    mediaItems: MediaItem[],
+    sendMedia: (msg: string, text: string, author: string) => void
+  ) => this.webshot(mediaItems, sendMedia, this.webshotDelay);
 
-  private workOnTweets = (
-    tweets: Tweets,
-    sendTweets: (msg: string, text: string, author: string) => void
-  ) => this.webshot(tweets, sendTweets, this.webshotDelay);
+  public urlSegmentToId = urlSegmentToId;
 
-  public getTweet = (id: string, sender: (msg: string, text: string, author: string) => void) => {
-    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);
+  public getMedia = (segmentId: string, sender: (msg: string, text: string, author: string) => void) =>
+    this.client.media.info(urlSegmentToId(segmentId))
+      .then(media => {
+        const mediaItem = media.items[0] as MediaItem;
+        logger.debug(`api returned media post ${JSON.stringify(mediaItem)} for query id=${segmentId}`);
+        return this.workOnMedia([mediaItem], sender);
       });
-  };
 
-  private sendTweets = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
+  private sendMedia = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
     to.forEach(subscriber => {
       logger.info(`pushing data${source ? ` of ${Message.ellipseBase64(source)}` : ''} to ${JSON.stringify(subscriber)}`);
       retryOnError(
@@ -315,70 +299,50 @@ export default class {
     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;
+    const promise = new Promise<UserFeedResponseItemsItem[]>(resolve => {
+      const match = /https:\/\/www\.instagram\.com\/([^\/]+)/.exec(currentFeed);
       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 as unknown as number;
-        if (offset > 0) config.since_id = offset;
-        this.client.get(endpoint, config, (error: {[key: string]: any}[], tweets, response) => {
-          if (error) {
-            if (error instanceof Array && error.length > 0 && error[0].code === 34) {
-              logger.warn(`error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
+        const feed = this.client.feed.user(lock.threads[currentFeed].id);
+        const newer = (item: UserFeedResponseItemsItem) =>
+          BigNumOps.compare(item.pk, lock.threads[currentFeed].offset) > 0;
+        const fetchMore = () => new Promise<UserFeedResponseItemsItem[]>(fetch => {
+          feed.request().then(response => {
+            if (response.items.length === 0) return fetch([]);
+            if (response.items.every(newer)) {
+              fetchMore().then(fetched => fetch(response.items.concat(fetched)));
+            } else fetch(response.items.filter(newer));
+          }, (error: IgClientError & Partial<RequestError>) => {
+            if (error instanceof IgNetworkError) {
+              logger.warn(`error on fetching media for ${currentFeed}: ${JSON.stringify(error.cause)}`);
+              if (!(error instanceof IgNotFoundError)) return;
               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)}`);
+              logger.error(`unhandled error on fetching media for ${currentFeed}: ${JSON.stringify(error)}`);
             }
-            resolve([]);
-          } else resolve(tweets);
+            fetch([]);
+          });
         });
+        fetchMore().then(resolve);
       }
     });
 
-    promise.then((tweets: Tweets) => {
-      logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
+    promise.then((mediaItems: MediaItem[]) => {
+      logger.debug(`api returned ${JSON.stringify(mediaItems)} for feed ${currentFeed}`);
       const currentThread = lock.threads[currentFeed];
 
       const updateDate = () => currentThread.updatedAt = new Date().toString();
-      if (!tweets || tweets.length === 0) { updateDate(); return; }
+      if (!mediaItems || mediaItems.length === 0) { updateDate(); return; }
 
-      const topOfFeed = tweets[0].id_str;
+      const topOfFeed = mediaItems[0].pk;
       const updateOffset = () => currentThread.offset = topOfFeed;
 
       if (currentThread.offset === '-1') { updateOffset(); return; }
-      if (currentThread.offset === '0') tweets.splice(1);
+      if (currentThread.offset === '0') mediaItems.splice(1);
 
-      return this.workOnTweets(tweets, this.sendTweets(`thread ${currentFeed}`, ...currentThread.subscribers))
+      return this.workOnMedia(mediaItems, this.sendMedia(`thread ${currentFeed}`, ...currentThread.subscribers))
         .then(updateDate).then(updateOffset);
     })
       .then(() => {

+ 62 - 145
src/webshot.ts

@@ -12,8 +12,8 @@ import * as temp from 'temp';
 
 import { getLogger } from './loggers';
 import { Message } from './koishi';
-import { MediaEntity, Tweets } from './twitter';
 import { chainPromises } from './utils';
+import { browserLogin, linkBuilder, MediaItem } from './twitter';
 
 const xmlEntities = new XmlEntities();
 
@@ -25,24 +25,28 @@ const ZHType = (type: string) => new class extends String {
 const typeInZH = {
   photo: ZHType('图片'),
   video: ZHType('视频'),
-  animated_gif: ZHType('GIF'),
 };
 
 const logger = getLogger('webshot');
 
-class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Promise<void>> {
+export type Page = puppeteer.Page;
+export type Cookies = puppeteer.Cookie[];
+
+class Webshot extends CallableInstance<[MediaItem[], (...args) => void, number], Promise<void>> {
 
   private browser: puppeteer.Browser;
   private mode: number;
   private wsUrl: string;
+  private getCookies: () => Cookies;
 
-  constructor(wsUrl: string, mode: number, onready?: (...args) => void) {
+  constructor(wsUrl: string, mode: number, getCookies: () => Cookies, 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.getCookies = getCookies;
       this.wsUrl = wsUrl;
       this.connect(onready);
     }
@@ -70,10 +74,6 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
       .then(() => this.connect(onready));
   };
 
-  private extendEntity = (media: MediaEntity) => {
-    logger.info('not working on a tweet');
-  };
-
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
     temp.track();
     const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
@@ -99,86 +99,46 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
           page.setViewportSize({
             width: width / zoomFactor,
             height: height / zoomFactor,
-          })
+          }).then(() => page.context().addCookies(this.getCookies()))
             .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-            // hide header, "more options" button, like and retweet count
-            .then(() => page.addStyleTag({
-              content: 'header{display:none!important}[data-testid="caret"],[data-testid="tweet"]+*>:nth-last-child(-n+2){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) {
-                    container.innerHTML = container.innerHTML;
-                    clearInterval(poll);
-                  }
-                });
-              }, 250);
-            }))
-            .then(() => page.waitForSelector('article', {timeout: getTimeout()}))
+            .then(() =>
+              (<T>(next: Promise<T>) => Promise.race([
+                page.click('button:has-text("すべて許可")').then(() => browserLogin(page))
+                  .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
+                  .then(() => next),
+                page.click('button:has-text("すべて許可")').then(() => next),
+                next,
+              ]))(page.waitForSelector('article', {timeout: getTimeout()}))
+            )
             .catch((err: Error): Promise<puppeteer.ElementHandle<Element> | null> => {
               if (err.name !== 'TimeoutError') throw err;
-              logger.warn(`navigation timed out at ${getTimerTime()} seconds`);
+              logger.warn(`navigation timed out at ${getTimerTime()} ms`);
               return null;
             })
-            .then(handle => {
-              if (handle === null) throw new puppeteer.errors.TimeoutError();
-            })
-            .then(() => page.evaluate(() => {
-              const cardImg = document.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,
-                  };
-                }
-              }
+            // hide header, "more options" button, like and retweet count
+            .then(() => page.addStyleTag({content:
+              'nav,footer,header+div,header+div+div>div>div+div,header div div div+div,' +
+              'article section,article section+div>ul>:not(div),article section+div~div,article button,canvas{display:none!important} ' +
+              'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important}',
             }))
-            .then(cardImg => {
-              if (cardImg) this.extendEntity(cardImg); 
-            })
-            .then(() => page.addScriptTag({
-              content: 'document.documentElement.scrollTop=0;',
+            .then(() => page.addStyleTag({
+              content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
             }))
-            .then(() => promisify(setTimeout)(getTimeout()))
             .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 = 0; y < this.height; y += zoomFactor) {
+                for (let y = this.height - 1; y > this.height - 1920; y -= zoomFactor) {
                   if (
-                    this.data[idx(x, y)] !== 255 &&
-                    this.data[idx(x, y)] === this.data[idx(x + zoomFactor * 10, y)]
+                    this.data[idx(zoomFactor, y)] <= 38 &&
+                    this.data[idx(zoomFactor, y)] === this.data[idx(this.width - zoomFactor, y)] &&
+                    this.data[idx(zoomFactor, y + zoomFactor)] === this.data[idx(zoomFactor, y - 2 * zoomFactor)]
                   ) {
-                    if (this.data[idx(x, y + 18 * zoomFactor)] !== 255) {
-                      // footer kicks in
-                      boundary = null;
-                    } else {
-                      boundary = y;
-                    }
+                    boundary = y;
                     break;
                   }
                 }
@@ -252,56 +212,31 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
       }
       logger.warn('unable to find MIME type of fetched media, failing this fetch');
       throw Error();
-    })(((/\?format=([a-z]+)&/.exec(url)) ?? (/.*\/.*\.([^?]+)/.exec(url)))[1])
+    })(/\/.*\.(.+?)\?/.exec(url)[1])
   );
 
   public webshot(
-    tweets: Tweets,
+    mediaItems: MediaItem[],
     callback: (msgs: string, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
     let promise = new Promise<void>(resolve => {
       resolve();
     });
-    tweets.forEach(twi => {
+    mediaItems.forEach(item => {
       promise = promise.then(() => {
-        logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
+        logger.info(`working on ${item.user.username}/${item.code}`);
       });
-      const originTwi = twi.retweeted_status || twi;
       let messageChain = '';
 
       // 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));
-      });
+      const author = `${item.user.full_name} (@${item.user.username}):\n`;
+      const text = item.caption.text;
+      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,
-            ],
-          };
-        };
+        const url = linkBuilder({postUrlSegment: item.code});
         promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
           .then(fileurl => {
             if (fileurl) return Message.Image(fileurl);
@@ -312,53 +247,35 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
           });
       }
       // fetch extra entities
+      const mediaType: keyof typeof typeInZH = item.video_versions ? 'video' : 'photo';
+      const fetchBestCandidate =
+        (candidates: typeof item.video_versions | typeof item.image_versions2.candidates) => {
+          const url = candidates
+            .sort((var1, var2) => var2.width - var1.width)
+            .map(variant => variant.url)[0]; // largest media
+          const altMessage = `\n[失败的${typeInZH[mediaType].type}:${url}]`;
+          return this.fetchMedia(url)
+            .catch(error => {
+              logger.warn('unable to fetch media, sending plain text instead...');
+              return altMessage;
+            })
+            .then(msg => { messageChain += msg; });
+        };
       // 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; });
-          }));
+        if (item.carousel_media) {
+          return chainPromises(item.carousel_media.map(carouselItem =>
+            fetchBestCandidate(carouselItem.image_versions2.candidates)
+          ));
+        } else if (item.video_versions) {
+          return fetchBestCandidate(item.video_versions);
+        } else if (item.image_versions2) {
+          return fetchBestCandidate(item.image_versions2.candidates);
         }
       });
-      // 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 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(`done working on ${item.user.username}/${item.code}, message chain:`);
         logger.info(JSON.stringify(Message.ellipseBase64(messageChain)));
         callback(messageChain, xmlEntities.decode(text), author);
       });