Mike L 3 سال پیش
والد
کامیت
1a04172f43
15فایلهای تغییر یافته به همراه32 افزوده شده و 2281 حذف شده
  1. 0 13
      config.example.json
  2. 6 128
      dist/command.js
  3. 0 51
      dist/datetime.js
  4. 2 21
      dist/koishi.js
  5. 7 83
      dist/main.js
  6. 0 469
      dist/twitter.js
  7. 0 287
      dist/webshot.js
  8. 0 13
      dist/webshot_test.js
  9. 8 128
      src/command.ts
  10. 0 41
      src/datetime.js
  11. 2 25
      src/koishi.ts
  12. 7 85
      src/main.ts
  13. 0 586
      src/twitter.ts
  14. 0 337
      src/webshot.ts
  15. 0 14
      src/webshot_test.js

+ 0 - 13
config.example.json

@@ -3,18 +3,5 @@
   "cq_ws_host": "127.0.0.1",
   "cq_ws_port": 6700,
   "cq_bot_qq": 10000,
-  "ig_socks_proxy": "",
-  "ig_username": "",
-  "ig_password": "",
-  "ig_session_lockfile": "",
-  "ig_2fa_code_receiver_port": 8081,
-  "mode": 0,
-  "playwright_ws_spec_endpoint": "http://127.0.0.1:8080/playwright-ws.json",
-  "resume_on_start": false,
-  "inactive_hours": ["3:00-7:00"],
-  "work_interval": 60,
-  "webshot_delay": 20000,
-  "webshot_cookies_lockfile": "",
-  "lockfile": "subscriber.lock",
   "loglevel": "info"
 }

+ 6 - 128
dist/command.js

@@ -1,13 +1,7 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-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");
-const loggers_1 = require("./loggers");
-const twitter_1 = require("./twitter");
-const utils_1 = require("./utils");
-const logger = loggers_1.getLogger('command');
+exports.view = exports.parseCmd = void 0;
+const koishi_1 = require("./koishi");
 function parseCmd(message) {
     message = message.trim();
     message = message.replace('\\\\', '\\0x5c');
@@ -28,132 +22,16 @@ function parseCmd(message) {
     };
 }
 exports.parseCmd = parseCmd;
-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];
-    const index = lock.threads[link].subscribers.findIndex(({ chatID, chatType }) => chat.chatID === chatID && chat.chatType === chatType);
-    return [link, index];
-}
-function sub(chat, args, reply, lock, lockfile) {
-    if (chat.chatType === "temp") {
-        return reply('请先添加机器人为好友。');
-    }
-    if (args.length === 0) {
-        return reply('找不到要订阅的链接。');
-    }
-    const matched = twitter_1.parseLink(args[0]);
-    if (!matched) {
-        return reply(`订阅链接格式错误:
-示例:
-https://www.instagram.com/tomoyo_kurosawa_/
-https://www.instagram.com/p/B6GHRSmgV-7/`);
-    }
-    let offset = '0';
-    const subscribeTo = (link, config = {}) => {
-        const { id, msg = `已为此聊天订阅 ${link}` } = config;
-        if (id) {
-            lock.feed.push(link);
-            lock.threads[link] = {
-                id,
-                offset,
-                subscribers: [],
-                updatedAt: '',
-            };
-        }
-        lock.threads[link].subscribers.push(chat);
-        logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
-        fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-        reply(msg);
-    };
-    const 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);
-        let msg;
-        if (offset !== '0') {
-            msg = `已为此聊天订阅 ${link} 并回溯到 ${args[0].replace(/\?.*/, '')}(含)之后的第一条动态`;
-        }
-        return subscribeTo(link, { id: Number(userName.split(':')[1]), msg });
-    };
-    if (matched.postUrlSegment) {
-        offset = utils_1.BigNumOps.plus(twitter_1.urlSegmentToId(matched.postUrlSegment), '-1');
-        twitter_1.getPostOwner(matched.postUrlSegment).then(userName => {
-            delete matched.postUrlSegment;
-            matched.userName = userName.split(':')[0];
-            if (!tryFindSub(matched.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);
-        });
-    }
-}
-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 = (_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.userName;
-    if (!match) {
-        return reply('链接格式有误。');
-    }
-    const [link, index] = linkFinder(match, chat, lock);
-    if (index === -1)
-        return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
-    else {
-        lock.threads[link].subscribers.splice(index, 1);
-        fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-        logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-        return reply(`已为此聊天退订 ${link}`);
-    }
-}
-exports.unsub = unsub;
-function list(chat, _, reply, lock) {
-    if (chat.chatType === "temp") {
-        return reply('请先添加机器人为好友。');
-    }
-    const links = [];
-    Object.keys(lock.threads).forEach(key => {
-        if (lock.threads[key].subscribers.find(({ chatID, chatType }) => chat.chatID === chatID && chat.chatType === chatType))
-            links.push(`${key} ${datetime_1.relativeDate(lock.threads[key].updatedAt)}`);
-    });
-    return reply('此聊天中订阅的 Instagram 动态链接:\n' + links.join('\n'));
-}
-exports.list = list;
 function view(chat, args, reply) {
-    var _a;
     if (args.length === 0) {
-        return reply('找不到要查看的链接。');
+        return reply('找不到要查看的回数。');
     }
-    const match = (_a = twitter_1.parseLink(args[0])) === null || _a === void 0 ? void 0 : _a.postUrlSegment;
-    if (!match) {
+    const match = Number(args[0]);
+    if (match < 1 || match > 999) {
         return reply('链接格式有误。');
     }
     try {
-        twitter_1.sendPost(match, chat);
+        reply(koishi_1.Message.Image(`https://d2n19nac4w0gh6.cloudfront.net/resource/images/webview/comic/story/comic_${String(match).padStart(3, '0')}.jpg`));
     }
     catch (e) {
         reply('机器人尚未加载完毕,请稍后重试。');

+ 0 - 51
dist/datetime.js

@@ -1,51 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.relativeDate = void 0;
-function relativeDate(dtstr) {
-    if (!dtstr)
-        return '暂无数据';
-    const dt = new Date(dtstr);
-    const dateTimeStamp = dt.getTime();
-    const second = 1000;
-    const minute = second * 60;
-    const hour = minute * 60;
-    const day = hour * 24;
-    const month = day * 30;
-    const now = new Date().getTime();
-    const diffValue = now - dateTimeStamp;
-    if (diffValue < 0) {
-        return;
-    }
-    const monthC = diffValue / month;
-    const weekC = diffValue / (7 * day);
-    const dayC = diffValue / day;
-    const hourC = diffValue / hour;
-    const minC = diffValue / minute;
-    const secC = diffValue / second;
-    let result;
-    if (monthC > 12) {
-        const y = dt.getFullYear() + ' 年';
-        const m = dt.getMonth() + 1 + ' 月';
-        const d = dt.getDate() + ' 日';
-        result = [y, m, d].join(' ');
-    }
-    else if (monthC >= 1) {
-        result = '' + Math.floor(monthC) + ' 个月前';
-    }
-    else if (weekC >= 1) {
-        result = '' + Math.floor(weekC) + ' 周前';
-    }
-    else if (dayC >= 1) {
-        result = '' + Math.floor(dayC) + ' 天前';
-    }
-    else if (hourC >= 1) {
-        result = '' + Math.floor(hourC) + ' 小时前';
-    }
-    else if (minC >= 1) {
-        result = '' + Math.floor(minC) + ' 分钟前';
-    }
-    else
-        result = '' + Math.floor(secC) + ' 秒前';
-    return result;
-}
-exports.relativeDate = relativeDate;

+ 2 - 21
dist/koishi.js

@@ -177,31 +177,12 @@ class default_1 {
                         .catch(error => { logger.error(`error replying to message from ${userString}, error: ${error}`); });
                 });
                 switch (cmdObj.cmd) {
-                    case 'instagram_view':
-                    case 'instagram_get':
+                    case 'nanatsu_view':
                         command_1.view(chat, cmdObj.args, reply);
                         break;
-                    case 'instagram_sub':
-                    case 'instagram_subscribe':
-                        this.botInfo.sub(chat, cmdObj.args, reply);
-                        break;
-                    case 'instagram_unsub':
-                    case 'instagram_unsubscribe':
-                        this.botInfo.unsub(chat, cmdObj.args, reply);
-                        break;
-                    case 'ping':
-                    case 'instagram':
-                        this.botInfo.list(chat, cmdObj.args, reply);
-                        break;
                     case 'help':
                         if (cmdObj.args.length === 0) {
-                            reply(`Instagram 搬运机器人:
-/instagram - 查询当前聊天中的 Instagram 动态订阅
-/instagram_sub[scribe]〈链接|用户名〉- 订阅 Instagram 媒体搬运
-/instagram_unsub[scribe]〈链接|用户名〉- 退订 Instagram 媒体搬运
-/instagram_view〈链接〉- 查看媒体\
-${chat.chatType === "temp" ?
-                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
+                            reply('Nanasta 通信搬运机器人:\n/nanatsu_view - 查看指定的 Nanasta 通信话数');
                         }
                 }
             }), true);

+ 7 - 83
dist/main.js

@@ -5,21 +5,19 @@ const fs = require("fs");
 const path = require("path");
 const commandLineUsage = require("command-line-usage");
 const exampleConfig = require("../config.example.json");
-const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
-const twitter_1 = require("./twitter");
 const logger = loggers_1.getLogger();
 const sections = [
     {
-        header: 'GoCQHTTP Instagram Bot',
-        content: 'The QQ Bot that forwards Instagram.',
+        header: 'GoCQHTTP Nana Bot',
+        content: 'The QQ Bot that does stuff.',
     },
     {
         header: 'Synopsis',
         content: [
-            '$ cq-instagram-bot {underline config.json}',
-            '$ cq-instagram-bot {bold --help}',
+            '$ cq-nana-bot {underline config.json}',
+            '$ cq-nana-bot {bold --help}',
         ],
     },
     {
@@ -47,100 +45,26 @@ catch (e) {
     process.exit(1);
 }
 const requiredFields = [
-    'ig_username', 'ig_password',
-    'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
+    'cq_bot_qq',
 ];
 const warningFields = [
     'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
-const optionalFields = [
-    'lockfile', 'inactive_hours', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start', 'ig_socks_proxy',
-].concat(warningFields);
 if (requiredFields.some((value) => config[value] === undefined)) {
     console.log(`${requiredFields.join(', ')} are required`);
     process.exit(1);
 }
-optionalFields.forEach(key => {
+warningFields.forEach(key => {
     if (config[key] === undefined || typeof (config[key]) !== typeof (exampleConfig[key])) {
-        if (warningFields.includes(key))
-            logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+        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')}`;
-    }
-});
-const k = 'ig_2fa_code_receiver_port';
-if (!config[k] || config[k] < 2048 || config[k] > 65536) {
-    logger.warn(`invalid value of config.${k}, use ${exampleConfig[k]} as default`);
-    config[k] = exampleConfig[k];
-}
 loggers_1.setLogLevels(config.loglevel);
-let lock;
-if (fs.existsSync(path.resolve(config.lockfile))) {
-    try {
-        lock = JSON.parse(fs.readFileSync(path.resolve(config.lockfile), 'utf8'));
-    }
-    catch (err) {
-        logger.error(`Failed to parse lockfile ${config.lockfile}: `, err);
-        lock = {
-            workon: 0,
-            feed: [],
-            threads: {},
-        };
-    }
-    fs.access(path.resolve(config.lockfile), fs.constants.W_OK, err => {
-        if (err) {
-            logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-            process.exit(1);
-        }
-    });
-}
-else {
-    lock = {
-        workon: 0,
-        feed: [],
-        threads: {},
-    };
-    try {
-        fs.writeFileSync(path.resolve(config.lockfile), JSON.stringify(lock));
-    }
-    catch (err) {
-        logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-        process.exit(1);
-    }
-}
-if (!config.resume_on_start) {
-    Object.keys(lock.threads).forEach(key => {
-        lock.threads[key].offset = '-1';
-    });
-}
 const qq = new koishi_1.default({
     access_token: config.cq_access_token,
     host: config.cq_ws_host,
     port: config.cq_ws_port,
     bot_id: config.cq_bot_qq,
-    list: (c, a, cb) => command_1.list(c, a, cb, lock),
-    sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
-    unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),
-});
-const worker = new twitter_1.default({
-    sessionLockfile: config.ig_session_lockfile,
-    credentials: [config.ig_username, config.ig_password],
-    codeServicePort: config.ig_2fa_code_receiver_port,
-    proxyUrl: config.ig_socks_proxy,
-    lock,
-    lockfile: config.lockfile,
-    inactiveHours: config.inactive_hours,
-    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.session.init().then(worker.launch);
 qq.connect();

+ 0 - 469
dist/twitter.js

@@ -1,469 +0,0 @@
-"use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
-    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
-    return new (P || (P = Promise))(function (resolve, reject) {
-        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
-        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
-        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
-        step((generator = generator.apply(thisArg, _arguments || [])).next());
-    });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.sendPost = exports.getPostOwner = exports.WebshotHelpers = exports.ScreenNameNormalizer = exports.SessionManager = exports.urlSegmentToId = exports.idToUrlSegment = exports.parseLink = exports.linkBuilder = exports.graphqlLinkBuilder = void 0;
-const crypto = require("crypto");
-const fs = require("fs");
-const http = require("http");
-const path = require("path");
-const url_1 = require("url");
-const util_1 = require("util");
-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; } });
-const instagram_private_api_1 = require("instagram-private-api");
-const socks_proxy_agent_1 = require("socks-proxy-agent");
-const loggers_1 = require("./loggers");
-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 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;
-const graphqlLinkBuilder = ({ userId, first = '12', after }) => `https://www.instagram.com/graphql/query/\
-?query_id=17888483320059182&id=${userId}&first=${first}${after ? `&after=${after}` : ''}`;
-exports.graphqlLinkBuilder = graphqlLinkBuilder;
-const urlSegmentToId = (urlSegment) => urlSegment.length <= 28 ?
-    instagram_id_to_url_segment_1.urlSegmentToInstagramId(urlSegment) : instagram_id_to_url_segment_1.urlSegmentToInstagramId(urlSegment.slice(0, -28));
-exports.urlSegmentToId = urlSegmentToId;
-class SessionManager {
-    constructor(client, file, credentials, codeServicePort) {
-        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().catch((err) => {
-                    logger.error(`error while trying to log in as user ${this.username}, error: ${err}`);
-                    logger.warn('attempting to retry after 1 minute...');
-                    if (fs.existsSync(filePath))
-                        fs.unlinkSync(filePath);
-                    util_1.promisify(setTimeout)(60000).then(this.init);
-                });
-            }
-        };
-        this.handle2FA = (submitter) => new Promise((resolve, reject) => {
-            const token = crypto.randomBytes(20).toString('hex');
-            logger.info('please submit the code with a one-time token from your browser with this path:');
-            logger.info(`/confirm-2fa?code=<the code you received>&token=${token}`);
-            let working;
-            const server = http.createServer((req, res) => {
-                const { pathname, query } = url_1.parse(req.url, true);
-                if (!working && pathname === '/confirm-2fa' && query.token === token &&
-                    typeof (query.code) === 'string' && /^\d{6}$/.test(query.code)) {
-                    const code = query.code;
-                    logger.debug(`received code: ${code}`);
-                    working = true;
-                    submitter(code)
-                        .then(response => { res.write('OK'); res.end(); server.close(() => resolve(response)); })
-                        .catch(err => { res.write('Error'); res.end(); reject(err); })
-                        .finally(() => { working = false; });
-                }
-            });
-            server.listen(this.codeServicePort);
-        });
-        this.login = () => this.ig.simulate.preLoginFlow()
-            .then(() => this.ig.account.login(this.username, this.password))
-            .catch((err) => {
-            if (err instanceof instagram_private_api_1.IgLoginTwoFactorRequiredError) {
-                const { two_factor_identifier, totp_two_factor_on } = err.response.body.two_factor_info;
-                logger.debug(`2FA info: ${JSON.stringify(err.response.body.two_factor_info)}`);
-                logger.info(`login is requesting two-factor authentication via ${totp_two_factor_on ? 'TOTP' : 'SMS'}`);
-                return this.handle2FA(code => this.ig.account.twoFactorLogin({
-                    username: this.username,
-                    verificationCode: code,
-                    twoFactorIdentifier: two_factor_identifier,
-                    verificationMethod: totp_two_factor_on ? '0' : '1',
-                }));
-            }
-            throw err;
-        })
-            .then(user => new Promise(resolve => {
-            logger.info(`successfully logged in as ${this.username}`);
-            process.nextTick(() => resolve(this.ig.simulate.postLoginFlow().then(() => user)));
-        }));
-        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;
-        this.codeServicePort = codeServicePort;
-    }
-}
-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 instanceof instagram_private_api_1.IgExactUserNotFoundError)) {
-                        logger.warn(`error looking up user: ${err.message}`);
-                        return `${username}:`;
-                    }
-                    return null;
-                });
-            }
-            return this.normalize(username);
-        });
-    }
-}
-exports.ScreenNameNormalizer = ScreenNameNormalizer;
-ScreenNameNormalizer.normalize = (username) => `${username.toLowerCase().replace(/^@/, '')}:`;
-let browserLogin = (page) => Promise.resolve();
-let browserSaveCookies = browserLogin;
-let isWaitingForLogin = false;
-const acceptCookieConsent = (page) => page.click('button:has-text("すべて許可")', { timeout: 5000 })
-    .then(() => logger.info('accepted cookie consent'))
-    .catch((err) => { if (err.name !== 'TimeoutError')
-    throw err; });
-exports.WebshotHelpers = {
-    handleLogin: browserLogin,
-    handleCookieConsent: acceptCookieConsent,
-    get isWaitingForLogin() { return isWaitingForLogin; },
-};
-let getPostOwner = (segmentId) => Promise.reject();
-exports.getPostOwner = getPostOwner;
-let sendPost = (segmentId, receiver) => {
-    throw Error();
-};
-exports.sendPost = sendPost;
-const logger = loggers_1.getLogger('instagram');
-const maxTrials = 3;
-const retryInterval = 1500;
-const ordinal = (n) => {
-    switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
-        case 1:
-            return `${n}st`;
-        case 2:
-            return `${n}nd`;
-        case 3:
-            return `${n}rd`;
-        default:
-            return `${n}th`;
-    }
-};
-const retryOnError = (doWork, onRetry) => new Promise(resolve => {
-    const retry = (reason, count) => {
-        setTimeout(() => {
-            let terminate = false;
-            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
-            if (!terminate)
-                doWork().then(resolve).catch(error => retry(error, count + 1));
-        }, retryInterval);
-    };
-    doWork().then(resolve).catch(error => retry(error, 1));
-});
-class default_1 {
-    constructor(opt) {
-        this.webshotCookies = [];
-        this.launch = () => {
-            this.webshot = new webshot_1.default(this.wsUrl, this.mode, () => this.webshotCookies, doOnNewPage => {
-                this.queryUserMedia = ((userName, targetId) => {
-                    let page;
-                    let url = linkBuilder({ userName }) + '?__a=1';
-                    logger.debug(`pulling ${targetId !== '0' ? `feed ${url} up to ${targetId}` : `top of feed ${url}`}...`);
-                    return doOnNewPage(newPage => {
-                        page = newPage;
-                        let timeout = this.webshotDelay / 2;
-                        const startTime = new Date().getTime();
-                        const getTimerTime = () => new Date().getTime() - startTime;
-                        const getTimeout = () => isWaitingForLogin ? 0 : Math.max(5000, timeout - getTimerTime());
-                        return page.context().addCookies(this.webshotCookies)
-                            .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                            .then(response => {
-                            const itemIds = [];
-                            const redirectionHandler = () => acceptCookieConsent(page)
-                                .then(() => browserLogin(page))
-                                .catch((err) => {
-                                if (err.name === 'TimeoutError') {
-                                    logger.warn('navigation timed out, assuming login has failed');
-                                    isWaitingForLogin = false;
-                                }
-                                throw err;
-                            })
-                                .then(() => browserSaveCookies(page))
-                                .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                                .then(responseHandler);
-                            const responseHandler = (res) => {
-                                if (res.status() !== 200) {
-                                    throw utils_1.customError('ResponseError')(`error navigating to user page, error was: ${res.status()} ${res.statusText()}`);
-                                }
-                                return res.json()
-                                    .catch(redirectionHandler)
-                                    .then((json) => {
-                                    var _a;
-                                    if (!json || !((_a = (json.graphql || json.data)) === null || _a === void 0 ? void 0 : _a.user)) {
-                                        logger.warn('error parsing graphql response, returning empty object...');
-                                        const data = { user: { edge_owner_to_timeline_media: { edges: [] } } };
-                                        return { graphql: data, data };
-                                    }
-                                    return json;
-                                });
-                            };
-                            const jsonHandler = ({ user }) => {
-                                const pageInfo = user.edge_owner_to_timeline_media.page_info;
-                                for (const { node } of user.edge_owner_to_timeline_media.edges) {
-                                    if (node.__typename === 'GraphVideo' && node.product_type === 'igtv')
-                                        continue;
-                                    if (node.id && utils_1.BigNumOps.compare(node.id, targetId) > 0)
-                                        itemIds.push(node.id);
-                                    else
-                                        return itemIds;
-                                    if (Number(targetId) < 1)
-                                        return itemIds;
-                                }
-                                if (!(pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.has_next_page))
-                                    return itemIds;
-                                logger.info('unable to find a smaller id than target, trying on next page...');
-                                url = graphqlLinkBuilder({ userId: user.id, after: pageInfo.end_cursor });
-                                const nextPageDelay = this.webshotDelay * (0.4 + Math.random() * 0.1);
-                                timeout += nextPageDelay;
-                                return util_1.promisify(setTimeout)(nextPageDelay)
-                                    .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                                    .then(responseHandler)
-                                    .then(({ data }) => jsonHandler(data));
-                            };
-                            return responseHandler(response)
-                                .then(({ graphql }) => jsonHandler(graphql));
-                        }).catch((err) => {
-                            if (err.name !== 'TimeoutError' && err.name !== 'ResponseError')
-                                throw err;
-                            if (err.name === 'ResponseError') {
-                                logger.warn(`error while fetching posts by @${userName}: ${err.message}`);
-                            }
-                            else
-                                logger.warn(`navigation timed out at ${getTimerTime()} ms`);
-                            return [];
-                        }).then(itemIds => util_1.promisify(setTimeout)(getTimeout()).then(() => itemIds.map(id => this.lazyGetMediaById(id))));
-                    }).finally(() => { page.close(); });
-                });
-                setTimeout(this.work, this.workInterval * 1000 / this.lock.feed.length);
-            });
-        };
-        this.queryUser = (username) => this.client.user.searchExact(username)
-            .then(user => `${user.username}:${user.pk}`);
-        this.workOnMedia = (lazyMediaItems, sendMedia) => this.webshot(lazyMediaItems, sendMedia, this.webshotDelay);
-        this.urlSegmentToId = urlSegmentToId;
-        this.lazyGetMediaById = (id) => ({
-            pk: id,
-            item: () => this.client.media.info(id).then(media => {
-                const mediaItem = media.items[0];
-                logger.debug(`api returned media post ${JSON.stringify(mediaItem)} for query id=${id}`);
-                return mediaItem;
-            }),
-        });
-        this.getMedia = (segmentId, sender) => this.workOnMedia([this.lazyGetMediaById(urlSegmentToId(segmentId))], sender);
-        this.sendMedia = (source, ...to) => (msg, text, author) => {
-            to.forEach(subscriber => {
-                logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
-                retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
-                    if (count <= maxTrials) {
-                        logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-                    }
-                    else {
-                        logger.warn(`${count - 1} consecutive failures while sending message chain, trying plain text instead...`);
-                        terminate(this.bot.sendTo(subscriber, author + text, true));
-                    }
-                });
-            });
-        };
-        this.work = () => {
-            const lock = this.lock;
-            if (this.workInterval < 1)
-                this.workInterval = 1;
-            if (this.isInactiveTime || lock.feed.length === 0) {
-                setTimeout(this.work, this.workInterval * 1000 / lock.feed.length);
-                return;
-            }
-            lock.feed.forEach((feed, index) => {
-                if (!lock.threads[feed] ||
-                    !lock.threads[feed].subscribers ||
-                    lock.threads[feed].subscribers.length === 0) {
-                    logger.warn(`nobody subscribes thread ${feed}, removing from feed`);
-                    delete lock.threads[index];
-                    lock.feed.splice(index, 1);
-                    fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-                }
-            });
-            const queuedFeeds = lock.feed.slice(0, (lock.workon + 1) || undefined).reverse();
-            utils_1.chainPromises(utils_1.Arr.chunk(queuedFeeds, 5).map((arr, i) => () => Promise.all(arr.map((currentFeed, j) => {
-                const workon = (queuedFeeds.length - 1) - (i * 5 + j);
-                fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-                const promiseDelay = this.workInterval * (Math.random() + j) * 250 / lock.feed.length;
-                const startTime = new Date().getTime();
-                const getTimerTime = () => new Date().getTime() - startTime;
-                const promise = util_1.promisify(setTimeout)(promiseDelay * 3).then(() => {
-                    logger.info(`about to pull from feed #${workon}: ${currentFeed}`);
-                    if (j === arr.length - 1)
-                        logger.info(`timeout for this batch job: ${Math.trunc(promiseDelay)} ms`);
-                    const match = /https:\/\/www\.instagram\.com\/([^\/]+)/.exec(currentFeed);
-                    if (!match) {
-                        logger.error(`current feed "${currentFeed}" is invalid, please remove this feed manually`);
-                        return [];
-                    }
-                    return this.queryUserMedia(match[1], this.lock.threads[currentFeed].offset)
-                        .catch((error) => {
-                        logger.error(`error scraping media off profile page of ${match[1]}, error: ${error}`);
-                        return [];
-                    });
-                }).then((mediaItems) => {
-                    const currentThread = lock.threads[currentFeed];
-                    const updateDate = () => currentThread.updatedAt = new Date().toString();
-                    if (!mediaItems || mediaItems.length === 0) {
-                        updateDate();
-                        return;
-                    }
-                    const topOfFeed = mediaItems[0].pk;
-                    const updateOffset = () => currentThread.offset = topOfFeed;
-                    if (currentThread.offset === '-1') {
-                        updateOffset();
-                        return;
-                    }
-                    return this.workOnMedia(mediaItems, this.sendMedia(`thread ${currentFeed}`, ...currentThread.subscribers))
-                        .then(updateDate).then(updateOffset);
-                }).then(() => {
-                    lock.workon = workon - 1;
-                    if (j === arr.length - 1) {
-                        logger.info(`batch job #${workon}-${workon + j} completed after ${getTimerTime()} ms`);
-                    }
-                    fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-                });
-                return Promise.race([promise, isWaitingForLogin ? utils_1.neverResolves() : util_1.promisify(setTimeout)(promiseDelay * 4)]);
-            })))).then(this.work);
-        };
-        this.client = new instagram_private_api_1.IgApiClient();
-        if (opt.proxyUrl) {
-            try {
-                const url = new URL(opt.proxyUrl);
-                if (!/^socks(?:4a?|5h?)?:$/.test(url.protocol))
-                    throw Error();
-                if (!url.port)
-                    url.port = '1080';
-                this.client.request.defaults.agent = new socks_proxy_agent_1.SocksProxyAgent({
-                    hostname: url.hostname,
-                    port: url.port,
-                    userId: url.username,
-                    password: url.password,
-                });
-            }
-            catch (e) {
-                logger.warn(`invalid socks proxy url: ${opt.proxyUrl}, ignoring`);
-            }
-        }
-        this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials, opt.codeServicePort);
-        this.lockfile = opt.lockfile;
-        this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
-        this.lock = opt.lock;
-        this.inactiveHours = opt.inactiveHours;
-        this.workInterval = opt.workInterval;
-        this.bot = opt.bot;
-        this.webshotDelay = opt.webshotDelay;
-        this.mode = opt.mode;
-        this.wsUrl = opt.wsUrl;
-        const cookiesFilePath = path.resolve(this.webshotCookiesLockfile);
-        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.message);
-            logger.warn('cookies will be saved to this file when needed');
-        }
-        browserLogin = page => page.fill('input[name="username"]', opt.credentials[0], { timeout: 0 })
-            .then(() => {
-            if (isWaitingForLogin !== true)
-                return;
-            logger.warn('still waiting for login, pausing execution...');
-            return utils_1.neverResolves();
-        })
-            .then(() => { isWaitingForLogin = true; logger.warn('blocked by login dialog, trying to log in manually...'); })
-            .then(() => page.fill('input[name="password"]', opt.credentials[1], { timeout: 0 }))
-            .then(() => page.click('button[type="submit"]', { timeout: 0 }))
-            .then(() => (next => Promise.race([
-            page.waitForSelector('#verificationCodeDescription', { timeout: 0 }).then(handle => handle.innerText()).then(text => {
-                logger.info(`login is requesting two-factor authentication via ${/認証アプリ/.test(text) ? 'TOTP' : 'SMS'}`);
-                return this.session.handle2FA(code => page.fill('input[name="verificationCode"]', code, { timeout: 0 }))
-                    .then(() => page.click('button:has-text("実行")', { timeout: 0 }))
-                    .then(next);
-            }),
-            next(),
-        ]))(() => page.click('button:has-text("情報を保存")', { timeout: 0 }).then(() => { isWaitingForLogin = false; })));
-        browserSaveCookies = page => 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');
-        });
-        exports.WebshotHelpers.handleLogin = page => browserLogin(page)
-            .then(() => page.waitForSelector('img[data-testid="user-avatar"]', { timeout: this.webshotDelay }))
-            .then(() => browserSaveCookies(page))
-            .catch((err) => {
-            if (err.name === 'TimeoutError') {
-                logger.warn('navigation timed out, assuming login has failed');
-                isWaitingForLogin = false;
-            }
-            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(urlSegmentToId(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)); });
-        };
-    }
-    get isInactiveTime() {
-        const timeToEpoch = (h = 0, m = 0) => new Date().setHours(h, m, 0, 0);
-        return this.inactiveHours
-            .map(rangeStr => ((start, end) => ({ start, end }))(...rangeStr.split('-', 2).map(timeStr => timeToEpoch(...timeStr.split(':', 2).map(Number)))))
-            .some(range => (now => now >= range.start && now < range.end)(Date.now()));
-    }
-}
-exports.default = default_1;

+ 0 - 287
dist/webshot.js

@@ -1,287 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-const fs_1 = require("fs");
-const util_1 = require("util");
-const axios_1 = require("axios");
-const CallableInstance = require("callable-instance");
-const html_entities_1 = require("html-entities");
-const pngjs_1 = require("pngjs");
-const puppeteer = require("playwright");
-const sharp = require("sharp");
-const temp = require("temp");
-const loggers_1 = require("./loggers");
-const koishi_1 = require("./koishi");
-const utils_1 = require("./utils");
-const twitter_1 = require("./twitter");
-const xmlEntities = new html_entities_1.XmlEntities();
-const ZHType = (type) => new class extends String {
-    constructor() {
-        super(...arguments);
-        this.type = super.toString();
-        this.toString = () => `[${super.toString()}]`;
-    }
-}(type);
-const typeInZH = {
-    photo: ZHType('图片'),
-    video: ZHType('视频'),
-};
-const logger = loggers_1.getLogger('webshot');
-class Webshot extends CallableInstance {
-    constructor(wsUrl, mode, getCookies, onready) {
-        super('webshot');
-        this.connect = (onready) => axios_1.default.get(this.wsUrl)
-            .then(res => {
-            logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
-            const browserType = Object.keys(res.data)[0];
-            return puppeteer[browserType]
-                .connect({ wsEndpoint: res.data[browserType] });
-        })
-            .then(browser => this.browser = browser)
-            .then(() => {
-            logger.info('launched puppeteer browser');
-            if (onready)
-                return onready();
-        })
-            .catch(error => this.reconnect(error, onready));
-        this.reconnect = (error, onready) => {
-            logger.error(`connection error, reason: ${error}`);
-            logger.warn('trying to reconnect in 2.5s...');
-            return util_1.promisify(setTimeout)(2500)
-                .then(() => this.connect(onready));
-        };
-        this.performOnNewPage = (action, zoomFactor = 2, reconnectOnError = true) => this.browser.newPage({
-            bypassCSP: true,
-            deviceScaleFactor: zoomFactor,
-            locale: 'ja-JP',
-            timezoneId: 'Asia/Tokyo',
-            userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
-        }).then(action)
-            .catch(error => {
-            if (reconnectOnError) {
-                return this.reconnect(error)
-                    .then(() => this.performOnNewPage(action, zoomFactor, reconnectOnError));
-            }
-            throw error;
-        });
-        this.renderWebshot = (url, height, webshotDelay, ...morePostProcessings) => {
-            temp.track();
-            const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
-            const sharpToFile = (pic) => new Promise(resolve => {
-                const webshotTempFilePath = temp.path({ suffix: '.jpg' });
-                pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
-            });
-            const promise = new Promise((resolve, reject) => {
-                const width = 720;
-                const zoomFactor = 2;
-                logger.info(`shooting ${width}*${height} webshot for ${url}`);
-                this.performOnNewPage(page => {
-                    const startTime = new Date().getTime();
-                    const getTimerTime = () => new Date().getTime() - startTime;
-                    const getTimeout = () => twitter_1.WebshotHelpers.isWaitingForLogin ? 0 : Math.max(500, webshotDelay - getTimerTime());
-                    page.setViewportSize({
-                        width: width / zoomFactor,
-                        height: height / zoomFactor,
-                    }).then(() => page.context().addCookies(this.getCookies()))
-                        .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                        .then(() => twitter_1.WebshotHelpers.handleCookieConsent(page))
-                        .then(() => ((next) => Promise.race([
-                        twitter_1.WebshotHelpers.handleLogin(page)
-                            .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                            .then(next),
-                        next(),
-                    ]))(() => util_1.promisify(setTimeout)(2000).then(() => page.waitForSelector('article', { timeout: getTimeout() }))))
-                        .catch((err) => {
-                        if (err.name !== 'TimeoutError')
-                            throw err;
-                        logger.warn(`navigation timed out at ${getTimerTime()} ms`);
-                        return null;
-                    })
-                        .then(() => page.addStyleTag({ content: 'nav,footer,main>*>*+*,header+div,header~div>div>div+div,main button,canvas,main section,main section+div>ul>:not(div),' +
-                            'main section+div>ul>div [role="button"],header~div [tabindex="0"]>*>[tabindex="-1"]~div{display:none!important} ' +
-                            'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important} ' +
-                            'main section+div>ul>div>li{padding:6px 2px 12px!important}',
-                    }))
-                        .then(() => page.addStyleTag({
-                        content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
-                    }))
-                        .then(() => page.evaluate(() => {
-                        let time;
-                        time = document.querySelector('div>div>time');
-                        if (time)
-                            time.parentElement.parentElement.style.display = 'none';
-                        time = document.querySelector('main section~div>a>time');
-                        if (time) {
-                            time.innerHTML = time.title + ' ' + new Date(time.dateTime).toLocaleTimeString().slice(0, -3);
-                            time.parentElement.parentElement.style.margin = '-24px 2px 12px';
-                            const element = time.parentElement.parentElement.nextElementSibling;
-                            if (element)
-                                element.style.display = 'none';
-                        }
-                    }))
-                        .then(() => utils_1.chainPromises(morePostProcessings.map(func => () => func(page))))
-                        .then(() => util_1.promisify(setTimeout)(getTimeout()))
-                        .then(() => page.screenshot())
-                        .then(screenshot => {
-                        new pngjs_1.PNG({
-                            filterType: 4,
-                            deflateLevel: 0,
-                        }).on('parsed', function () {
-                            const idx = (x, y) => (this.width * y + x) << 2;
-                            let boundary = null;
-                            for (let y = this.height - 1; y > this.height - 3840; 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 - 1;
-                                    break;
-                                }
-                            }
-                            if (boundary !== null) {
-                                logger.info(`found boundary at ${boundary}, cropping image`);
-                                this.data = this.data.slice(0, idx(this.width, boundary));
-                                this.height = boundary;
-                                sharpToFile(jpeg(this.pack())).then(path => {
-                                    logger.info(`finished webshot for ${url}`);
-                                    resolve({ path, boundary });
-                                });
-                            }
-                            else if (height >= 8 * 1920) {
-                                logger.warn('too large, consider as a bug, returning');
-                                sharpToFile(jpeg(this.pack())).then(path => {
-                                    resolve({ path, boundary: 0 });
-                                });
-                            }
-                            else {
-                                logger.info('unable to find boundary, try shooting a larger image');
-                                resolve({ path: '', boundary });
-                            }
-                        }).parse(screenshot);
-                    })
-                        .catch(err => {
-                        if (err instanceof Error && err.name !== 'TimeoutError')
-                            throw err;
-                        logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-                        resolve({ path: '', boundary: 0 });
-                    })
-                        .finally(() => { page.close(); });
-                }, zoomFactor, false)
-                    .catch(reject);
-            });
-            return promise.then(data => {
-                if (data.boundary === null) {
-                    return this.renderWebshot(url, height + 3840, webshotDelay, ...morePostProcessings);
-                }
-                else
-                    return data.path;
-            }).catch(error => this.reconnect(error)
-                .then(() => this.renderWebshot(url, height, webshotDelay, ...morePostProcessings)));
-        };
-        this.fetchMedia = (url) => new Promise((resolve, reject) => {
-            logger.info(`fetching ${url}`);
-            axios_1.default({
-                method: 'get',
-                url,
-                responseType: 'arraybuffer',
-                timeout: 150000,
-            }).then(res => {
-                if (res.status === 200) {
-                    logger.info(`successfully fetched ${url}`);
-                    resolve(res.data);
-                }
-                else {
-                    logger.error(`failed to fetch ${url}: ${res.status}`);
-                    reject();
-                }
-            }).catch(err => {
-                logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
-                reject();
-            });
-        }).then(data => (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) {
-            if (onready)
-                onready();
-        }
-        else {
-            this.getCookies = getCookies;
-            this.wsUrl = wsUrl;
-            this.connect(() => onready && onready(this.performOnNewPage));
-        }
-    }
-    webshot(lazyMediaItems, callback, webshotDelay) {
-        let grandPromise = Promise.resolve();
-        lazyMediaItems.forEach(lazyItem => grandPromise = grandPromise.then(lazyItem.item).then(item => {
-            var _a;
-            let promise = Promise.resolve();
-            promise = promise.then(() => {
-                logger.info(`working on ${item.user.username}/${item.code}`);
-            });
-            let messageChain = '';
-            const author = `${item.user.full_name} (@${item.user.username}):\n`;
-            const text = ((_a = item.caption) === null || _a === void 0 ? void 0 : _a.text) || '';
-            if (this.mode > 0)
-                messageChain += (author + xmlEntities.decode(text));
-            if (this.mode === 0) {
-                const url = twitter_1.linkBuilder({ postUrlSegment: item.code });
-                promise = promise.then(() => this.renderWebshot(url, 3840, webshotDelay, page => page.addStyleTag({ content: 'header>div>div+div{font-size:12px; line-height:15px; padding-top:0!important}' +
-                        `header>div>div+div::before{content:"${item.user.full_name}"; color:#8e8e8e; font-weight:bold}`,
-                })))
-                    .then(fileurl => {
-                    if (fileurl)
-                        return koishi_1.Message.Image(fileurl);
-                    return author + text;
-                })
-                    .then(msg => {
-                    if (msg)
-                        messageChain += msg;
-                });
-            }
-            const type = (mediaItem) => mediaItem.video_versions ? 'video' : 'photo';
-            const fetchBestCandidate = (candidates, mediaType) => {
-                const url = candidates
-                    .sort((var1, var2) => var2.width + ((var2 === null || var2 === void 0 ? void 0 : var2.type) || 0) - var1.width - ((var1 === null || var1 === void 0 ? void 0 : var1.type) || 0))
-                    .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 (item.carousel_media) {
-                        return utils_1.chainPromises(item.carousel_media.map(carouselItem => () => fetchBestCandidate(carouselItem.video_versions ||
-                            carouselItem.image_versions2.candidates, type(carouselItem))));
-                    }
-                    else if (item.video_versions) {
-                        return fetchBestCandidate(item.video_versions, type(item));
-                    }
-                    else if (item.image_versions2) {
-                        return fetchBestCandidate(item.image_versions2.candidates, type(item));
-                    }
-                });
-            promise.then(() => {
-                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);
-            });
-            return promise;
-        }));
-        return grandPromise;
-    }
-}
-exports.default = Webshot;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 13
dist/webshot_test.js


+ 8 - 128
src/command.ts

@@ -1,18 +1,8 @@
 /* eslint-disable @typescript-eslint/no-unsafe-return */
 /* eslint-disable @typescript-eslint/member-delimiter-style */
 /* eslint-disable prefer-arrow/prefer-arrow-functions */
-import * as fs from 'fs';
-import * as path from 'path';
 
-import { relativeDate } from './datetime';
-import { getLogger } from './loggers';
-import {
-  getPostOwner, sendPost, ScreenNameNormalizer as normalizer,
-  linkBuilder, parseLink, urlSegmentToId
-} from './twitter';
-import { BigNumOps } from './utils';
-
-const logger = getLogger('command');
+import { Message } from './koishi';
 
 function parseCmd(message: string): {
   cmd: string;
@@ -37,131 +27,21 @@ function parseCmd(message: string): {
   };
 }
 
-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()
-  );
-  if (!link) return [null, -1];
-  const index = lock.threads[link].subscribers.findIndex(({chatID, chatType}) =>
-    chat.chatID === chatID && chat.chatType === chatType
-  );
-  return [link, index];
-}
-
-function sub(chat: IChat, args: string[], reply: (msg: string) => any,
-  lock: ILock, lockfile: string
-): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  if (args.length === 0) {
-    return reply('找不到要订阅的链接。');
-  }
-  const matched = parseLink(args[0]);
-  if (!matched) {
-    return reply(`订阅链接格式错误:
-示例:
-https://www.instagram.com/tomoyo_kurosawa_/
-https://www.instagram.com/p/B6GHRSmgV-7/`);
-  }
-  let offset = '0';
-  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: '',
-      };
-    }
-    lock.threads[link].subscribers.push(chat);
-    logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
-    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-    reply(msg);
-  };
-  const 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);
-    let msg: string;
-    if (offset !== '0') {
-      msg = `已为此聊天订阅 ${link} 并回溯到 ${args[0].replace(/\?.*/, '')}(含)之后的第一条动态`;
-    }
-    return subscribeTo(link, {id: Number(userName.split(':')[1]), msg});
-  };
-  if (matched.postUrlSegment) {
-    offset = BigNumOps.plus(urlSegmentToId(matched.postUrlSegment), '-1');
-    getPostOwner(matched.postUrlSegment).then(userName => {
-      delete matched.postUrlSegment;
-      matched.userName = userName.split(':')[0];
-      if (!tryFindSub(matched.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);
-    });
-  }
-}
-
-function unsub(chat: IChat, args: string[], reply: (msg: string) => any,
-  lock: ILock, lockfile: string
-): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  if (args.length === 0) {
-    return reply('找不到要退订的链接。');
-  }
-  const match = parseLink(args[0])?.userName;
-  if (!match) {
-    return reply('链接格式有误。');
-  }
-  const [link, index] = linkFinder(match, chat, lock);
-  if (index === -1) return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
-  else {
-    lock.threads[link].subscribers.splice(index, 1);
-    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-    logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-    return reply(`已为此聊天退订 ${link}`);
-  }
-}
-
-function list(chat: IChat, _: string[], reply: (msg: string) => any, lock: ILock): void {
-  if (chat.chatType === ChatType.Temp) {
-    return reply('请先添加机器人为好友。');
-  }
-  const links = [];
-  Object.keys(lock.threads).forEach(key => {
-    if (lock.threads[key].subscribers.find(({chatID, chatType}) => 
-      chat.chatID === chatID && chat.chatType === chatType
-    )) links.push(`${key} ${relativeDate(lock.threads[key].updatedAt)}`);
-  });
-  return reply('此聊天中订阅的 Instagram 动态链接:\n' + links.join('\n'));
-}
-
 function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
   if (args.length === 0) {
-    return reply('找不到要查看的链接。');
+    return reply('找不到要查看的回数。');
   }
-  const match = parseLink(args[0])?.postUrlSegment;
-  if (!match) {
+  const match = Number(args[0]);
+  if (match < 1 || match > 999) {
     return reply('链接格式有误。');
   }
   try {
-    sendPost(match, chat);
+    reply(Message.Image(`https://d2n19nac4w0gh6.cloudfront.net/resource/images/webview/comic/story/comic_${
+      String(match).padStart(3, '0')
+    }.jpg`));
   } catch (e) {
     reply('机器人尚未加载完毕,请稍后重试。');
   }
 }
 
-export { parseCmd, sub, list, unsub, view };
+export { parseCmd, view };

+ 0 - 41
src/datetime.js

@@ -1,41 +0,0 @@
-function relativeDate(dtstr) {
-  if (!dtstr) return '暂无数据';
-  const dt = new Date(dtstr);
-  const dateTimeStamp = dt.getTime();
-  const second = 1000;
-  const minute = second * 60;
-  const hour = minute * 60;
-  const day = hour * 24;
-  const month = day * 30;
-  const now = new Date().getTime();
-  const diffValue = now - dateTimeStamp;
-  if (diffValue < 0) {
-    return;
-  }
-  const monthC = diffValue / month;
-  const weekC = diffValue / (7 * day);
-  const dayC = diffValue / day;
-  const hourC = diffValue / hour;
-  const minC = diffValue / minute;
-  const secC = diffValue / second;
-  let result;
-  if (monthC > 12) {
-    const y = dt.getFullYear() + ' 年';
-    const m = dt.getMonth() + 1 + ' 月';
-    const d = dt.getDate() + ' 日';
-    result = [y, m, d].join(' ');
-  } else if (monthC >= 1) {
-    result = '' + Math.floor(monthC) + ' 个月前';
-  } else if (weekC >= 1) {
-    result = '' + Math.floor(weekC) + ' 周前';
-  } else if (dayC >= 1) {
-    result = '' + Math.floor(dayC) + ' 天前';
-  } else if (hourC >= 1) {
-    result = '' + Math.floor(hourC) + ' 小时前';
-  } else if (minC >= 1) {
-    result = '' + Math.floor(minC) + ' 分钟前';
-  } else result = '' + Math.floor(secC) + ' 秒前';
-  return result;
-}
-
-export { relativeDate };

+ 2 - 25
src/koishi.ts

@@ -15,9 +15,6 @@ interface IQQProps {
   host: string;
   port: number;
   bot_id: number;
-  list(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  sub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
-  unsub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
 }
 
 const cqUrlFix = (factory: segment.Factory<string | ArrayBuffer | Buffer>) =>
@@ -196,32 +193,12 @@ export default class {
           .catch(error => { logger.error(`error replying to message from ${userString}, error: ${error}`); });
       };
       switch (cmdObj.cmd) {
-        case 'instagram_view':
-        case 'instagram_get':
+        case 'nanatsu_view':
           view(chat, cmdObj.args, reply);
           break;
-        case 'instagram_sub':
-        case 'instagram_subscribe':
-          this.botInfo.sub(chat, cmdObj.args, reply);
-          break;
-        case 'instagram_unsub':
-        case 'instagram_unsubscribe':
-          this.botInfo.unsub(chat, cmdObj.args, reply);
-          break;
-        case 'ping':
-        case 'instagram':
-          this.botInfo.list(chat, cmdObj.args, reply);
-          break;
         case 'help':
           if (cmdObj.args.length === 0) {
-            reply(`Instagram 搬运机器人:
-/instagram - 查询当前聊天中的 Instagram 动态订阅
-/instagram_sub[scribe]〈链接|用户名〉- 订阅 Instagram 媒体搬运
-/instagram_unsub[scribe]〈链接|用户名〉- 退订 Instagram 媒体搬运
-/instagram_view〈链接〉- 查看媒体\
-${chat.chatType === ChatType.Temp ?
-    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
-}`);
+            reply('Nanasta 通信搬运机器人:\n/nanatsu_view - 查看指定的 Nanasta 通信话数');
           }
       }
     }, true);

+ 7 - 85
src/main.ts

@@ -6,23 +6,21 @@ import * as path from 'path';
 import * as commandLineUsage from 'command-line-usage';
 
 import * as exampleConfig from '../config.example.json';
-import { list, sub, unsub } from './command';
 import { getLogger, setLogLevels } from './loggers';
 import QQBot from './koishi';
-import Worker from './twitter';
 
 const logger = getLogger();
 
 const sections: commandLineUsage.Section[] = [
   {
-    header: 'GoCQHTTP Instagram Bot',
-    content: 'The QQ Bot that forwards Instagram.',
+    header: 'GoCQHTTP Nana Bot',
+    content: 'The QQ Bot that does stuff.',
   },
   {
     header: 'Synopsis',
     content: [
-      '$ cq-instagram-bot {underline config.json}',
-      '$ cq-instagram-bot {bold --help}',
+      '$ cq-nana-bot {underline config.json}',
+      '$ cq-nana-bot {bold --help}',
     ],
   },
   {
@@ -56,108 +54,32 @@ try {
 }
 
 const requiredFields = [
-  'ig_username', 'ig_password',
-  'cq_bot_qq', ...(config.mode || exampleConfig.mode) === 0 ? ['playwright_ws_spec_endpoint'] : [],
+  'cq_bot_qq',
 ];
 
 const warningFields = [
   'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
 
-const optionalFields = [
-  'lockfile', 'inactive_hours', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start', 'ig_socks_proxy',
-].concat(warningFields);
-
 if (requiredFields.some((value) => config[value] === undefined)) {
   console.log(`${requiredFields.join(', ')} are required`);
   process.exit(1);
 }
 
-optionalFields.forEach(key => {
+warningFields.forEach(key => {
   if (config[key] === undefined || typeof(config[key]) !== typeof (exampleConfig[key])) {
-    if (warningFields.includes(key)) logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+    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')}`;
-  }
-});
-
-const k = 'ig_2fa_code_receiver_port';
-if (!config[k] || config[k] < 2048 || config[k] > 65536) {
-  logger.warn(`invalid value of config.${k}, use ${exampleConfig[k]} as default`);
-  config[k] = exampleConfig[k];
-}
-
 setLogLevels(config.loglevel);
 
-let lock: ILock;
-if (fs.existsSync(path.resolve(config.lockfile))) {
-  try {
-    lock = JSON.parse(fs.readFileSync(path.resolve(config.lockfile), 'utf8')) as ILock;
-  } catch (err) {
-    logger.error(`Failed to parse lockfile ${config.lockfile}: `, err);
-    lock = {
-      workon: 0,
-      feed: [],
-      threads: {},
-    };
-  }
-  fs.access(path.resolve(config.lockfile), fs.constants.W_OK, err => {
-    if (err) {
-      logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-      process.exit(1);
-    }
-  });
-} else {
-  lock = {
-    workon: 0,
-    feed: [],
-    threads: {},
-  };
-  try {
-    fs.writeFileSync(path.resolve(config.lockfile), JSON.stringify(lock));
-  } catch (err) {
-    logger.fatal(`cannot write lockfile ${path.resolve(config.lockfile)}, permission denied`);
-    process.exit(1);
-  }
-}
-
-if (!config.resume_on_start) {
-  Object.keys(lock.threads).forEach(key => {
-    lock.threads[key].offset = '-1';
-  });
-}
-
 const qq = new QQBot({
   access_token: config.cq_access_token,
   host: config.cq_ws_host,
   port: config.cq_ws_port,
   bot_id: config.cq_bot_qq,
-  list: (c, a, cb) => list(c, a, cb, lock),
-  sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
-  unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),
-});
-
-const worker = new Worker({
-  sessionLockfile: config.ig_session_lockfile,
-  credentials: [config.ig_username, config.ig_password],
-  codeServicePort: config.ig_2fa_code_receiver_port,
-  proxyUrl: config.ig_socks_proxy,
-  lock,
-  lockfile: config.lockfile,
-  inactiveHours: config.inactive_hours,
-  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.session.init().then(worker.launch);
 
 qq.connect();

+ 0 - 586
src/twitter.ts

@@ -1,586 +0,0 @@
-import * as crypto from 'crypto';
-import * as fs from 'fs';
-import * as http from 'http';
-import * as path from 'path';
-import { parse as parseUrl } from 'url';
-import { promisify } from 'util';
-
-import {
-  instagramIdToUrlSegment as idToUrlSegment,
-  urlSegmentToInstagramId as pubUrlSegmentToId
-} from 'instagram-id-to-url-segment';
-import {
-  IgApiClient,
-  IgClientError, IgExactUserNotFoundError, IgLoginTwoFactorRequiredError, IgResponseError,
-  MediaInfoResponseItemsItem, UserFeedResponseItemsItem
-} from 'instagram-private-api';
-import { SocksProxyAgent } from 'socks-proxy-agent';
-
-import { getLogger } from './loggers';
-import QQBot from './koishi';
-import { Arr, BigNumOps, chainPromises, customError, neverResolves } 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 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}/`;
-};
-
-const graphqlLinkBuilder = ({userId, first = '12', after}: {userId: string, first?: string, after?: string}) =>
-  `https://www.instagram.com/graphql/query/\
-?query_id=17888483320059182&id=${userId}&first=${first}${after ? `&after=${after}` : ''}`;
-
-const urlSegmentToId = (urlSegment: string) => urlSegment.length <= 28 ?
-  pubUrlSegmentToId(urlSegment) : pubUrlSegmentToId(urlSegment.slice(0, -28));
-
-export { graphqlLinkBuilder, linkBuilder, parseLink, idToUrlSegment, urlSegmentToId };
-
-interface IWorkerOption {
-  sessionLockfile: string;
-  credentials: [string, string];
-  codeServicePort: number;
-  proxyUrl: string;
-  lock: ILock;
-  lockfile: string;
-  webshotCookiesLockfile: string;
-  bot: QQBot;
-  inactiveHours: string[];
-  workInterval: number;
-  webshotDelay: number;
-  mode: number;
-  wsUrl: string;
-}
-
-export class SessionManager {
-  private ig: IgApiClient;
-  private username: string;
-  private password: string;
-  private lockfile: string;
-  private codeServicePort: number;
-
-  constructor(client: IgApiClient, file: string, credentials: [string, string], codeServicePort: number) {
-    this.ig = client;
-    this.lockfile = file;
-    [this.username, this.password] = credentials;
-    this.codeServicePort = codeServicePort;
-  }
-
-  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().catch((err: IgClientError) => {
-        logger.error(`error while trying to log in as user ${this.username}, error: ${err}`);
-        logger.warn('attempting to retry after 1 minute...');
-        if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
-        promisify(setTimeout)(60000).then(this.init);
-      });
-    }
-  };
-
-  public handle2FA = <T>(submitter: (code: string) => Promise<T>) => new Promise<T>((resolve, reject) => {
-    const token = crypto.randomBytes(20).toString('hex');
-    logger.info('please submit the code with a one-time token from your browser with this path:');
-    logger.info(`/confirm-2fa?code=<the code you received>&token=${token}`);
-    let working;
-    const server = http.createServer((req, res) => {
-      const {pathname, query} = parseUrl(req.url, true);
-      if (!working && pathname === '/confirm-2fa' && query.token === token &&
-        typeof(query.code) === 'string' && /^\d{6}$/.test(query.code)) {
-        const code = query.code;
-        logger.debug(`received code: ${code}`);
-        working = true;
-        submitter(code)
-          .then(response => { res.write('OK'); res.end(); server.close(() => resolve(response)); })
-          .catch(err => { res.write('Error'); res.end(); reject(err); })
-          .finally(() => { working = false; });
-      }
-    });
-    server.listen(this.codeServicePort);
-  });
-
-  public login = () =>
-    this.ig.simulate.preLoginFlow()
-      .then(() => this.ig.account.login(this.username, this.password))
-      .catch((err: IgClientError) => {
-        if (err instanceof IgLoginTwoFactorRequiredError) {
-          const {two_factor_identifier, totp_two_factor_on} = err.response.body.two_factor_info;
-          logger.debug(`2FA info: ${JSON.stringify(err.response.body.two_factor_info)}`);
-          logger.info(`login is requesting two-factor authentication via ${totp_two_factor_on ? 'TOTP' : 'SMS'}`);
-          return this.handle2FA(code => this.ig.account.twoFactorLogin({
-            username: this.username,
-            verificationCode: code,
-            twoFactorIdentifier: two_factor_identifier,
-            verificationMethod: totp_two_factor_on ? '0' : '1',
-          }));
-        }
-        throw err;
-      })
-      .then(user => new Promise<typeof user>(resolve => {
-        logger.info(`successfully logged in as ${this.username}`);
-        process.nextTick(() => resolve(this.ig.simulate.postLoginFlow().then(() => user)));
-      }));
-
-  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 async normalizeLive(username: string) {
-    if (this._queryUser) {
-      return await this._queryUser(username)
-        .catch((err: IgClientError) => {
-          if (!(err instanceof IgExactUserNotFoundError)) {
-            logger.warn(`error looking up user: ${err.message}`);
-            return `${username}:`;
-          }
-          return null;
-        });
-    }
-    return this.normalize(username);
-  }
-}
-
-let browserLogin = (page: Page): Promise<void> => Promise.resolve();
-
-let browserSaveCookies = browserLogin;
-
-let isWaitingForLogin = false;
-
-const acceptCookieConsent = (page: Page) =>
-  page.click('button:has-text("すべて許可")', { timeout: 5000 })
-    .then(() => logger.info('accepted cookie consent'))
-    .catch((err: Error) => { if (err.name !== 'TimeoutError') throw err; });
-
-export const WebshotHelpers = {
-  handleLogin: browserLogin,
-  handleCookieConsent: acceptCookieConsent,
-  get isWaitingForLogin() { return isWaitingForLogin; },
-};
-
-export let getPostOwner = (segmentId: string): Promise<string> => Promise.reject();
-
-export let sendPost = (segmentId: string, receiver: IChat): void => {
-  throw Error();
-};
-
-type IgGraphQLTimelineMediaNode = {
-  id: string,
-  display_url: string,
-  owner: {
-    id: string,
-    username?: string,
-  },
-} & (
-  {__typename: 'GraphImage'} |
-  {__typename: 'GraphSidecar', edge_sidecar_to_children: {
-    edges: {node: (IgGraphQLTimelineMediaNode & {__typename: 'GraphImage'})}[],
-  }} |
-  {__typename: 'GraphVideo', video_url: string, product_type?: 'igtv'}
-);
-
-export type IgGraphQLUser = {
-  biography?: string,
-  fbid: string,
-  full_name: string,
-  id: string,
-  username: string,
-  edge_owner_to_timeline_media: {
-    count: number,
-    page_info: {
-      has_next_page: boolean,
-      end_cursor: string | null,
-    },
-    edges: {node: IgGraphQLTimelineMediaNode}[],
-  },
-};
-
-export type MediaItem = MediaInfoResponseItemsItem & UserFeedResponseItemsItem;
-
-export type LazyMediaItem = {
-  pk: string,
-  item: () => Promise<MediaItem>,
-};
-
-const logger = getLogger('instagram');
-const maxTrials = 3;
-const retryInterval = 1500;
-const ordinal = (n: number) => {
-  switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
-    case 1:
-      return `${n}st`;
-    case 2:
-      return `${n}nd`;
-    case 3:
-      return `${n}rd`;
-    default:
-      return `${n}th`;
-  }
-};
-const retryOnError = <T, U>(
-  doWork: () => Promise<T>,
-  onRetry: (error, count: number, terminate: (defaultValue: U) => void) => void
-) => new Promise<T | U>(resolve => {
-  const retry = (reason, count: number) => {
-    setTimeout(() => {
-      let terminate = false;
-      onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
-      if (!terminate) doWork().then(resolve).catch(error => retry(error, count + 1));
-    }, retryInterval);
-  };
-  doWork().then(resolve).catch(error => retry(error, 1));
-});
-
-export default class {
-
-  private client: IgApiClient;
-  private lock: ILock;
-  private lockfile: string;
-  private inactiveHours: 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 IgApiClient();
-    if (opt.proxyUrl) {
-      try {
-        const url = new URL(opt.proxyUrl);
-        if (!/^socks(?:4a?|5h?)?:$/.test(url.protocol)) throw Error();
-        if (!url.port) url.port = '1080';
-        this.client.request.defaults.agent = new SocksProxyAgent({
-          hostname: url.hostname,
-          port: url.port,
-          userId: url.username,
-          password: url.password,
-        });
-      } catch (e) {
-        logger.warn(`invalid socks proxy url: ${opt.proxyUrl}, ignoring`);
-      }
-    }
-    this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials, opt.codeServicePort);
-    this.lockfile = opt.lockfile;
-    this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
-    this.lock = opt.lock;
-    this.inactiveHours = opt.inactiveHours;
-    this.workInterval = opt.workInterval;
-    this.bot = opt.bot;
-    this.webshotDelay = opt.webshotDelay;
-    this.mode = opt.mode;
-    this.wsUrl = opt.wsUrl;
-
-    const cookiesFilePath = path.resolve(this.webshotCookiesLockfile);
-    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 as Error).message
-      );
-      logger.warn('cookies will be saved to this file when needed');
-    }
-
-    browserLogin = page =>
-      page.fill('input[name="username"]', opt.credentials[0], {timeout: 0})
-        .then(() => {
-          if (isWaitingForLogin !== true) return;
-          logger.warn('still waiting for login, pausing execution...'); return neverResolves();
-        })
-        .then(() => { isWaitingForLogin = true; logger.warn('blocked by login dialog, trying to log in manually...'); })
-        .then(() => page.fill('input[name="password"]', opt.credentials[1], {timeout: 0}))
-        .then(() => page.click('button[type="submit"]', {timeout: 0}))
-        .then(() =>
-          (next => Promise.race([
-            page.waitForSelector('#verificationCodeDescription', {timeout: 0}).then(handle => handle.innerText()).then(text => {
-              logger.info(`login is requesting two-factor authentication via ${/認証アプリ/.test(text) ? 'TOTP' : 'SMS'}`);
-              return this.session.handle2FA(code => page.fill('input[name="verificationCode"]', code, {timeout: 0}))
-                .then(() => page.click('button:has-text("実行")', {timeout: 0}))
-                .then(next);
-            }),
-            next(),
-          ]))(() => page.click('button:has-text("情報を保存")', {timeout: 0}).then(() => { isWaitingForLogin = false; }))
-        );
-    browserSaveCookies = page =>
-      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');
-        });
-    WebshotHelpers.handleLogin = page =>
-      browserLogin(page)
-        .then(() => page.waitForSelector('img[data-testid="user-avatar"]', { timeout: this.webshotDelay }))
-        .then(() => browserSaveCookies(page))
-        .catch((err: Error) => {
-          if (err.name === 'TimeoutError') {
-            logger.warn('navigation timed out, assuming login has failed');
-            isWaitingForLogin = false;
-          }
-          throw err;
-        });
-    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)); });
-    };
-  }
-
-  public launch = () => {
-    this.webshot = new Webshot(
-      this.wsUrl,
-      this.mode,
-      () => this.webshotCookies,
-      doOnNewPage => {
-        this.queryUserMedia = ((userName, targetId) => {
-          let page: Page;
-          let url = linkBuilder({userName}) + '?__a=1';
-          logger.debug(`pulling ${targetId !== '0' ? `feed ${url} up to ${targetId}` : `top of feed ${url}`}...`);
-          return doOnNewPage(newPage => {
-            page = newPage;
-            let timeout = this.webshotDelay / 2;
-            const startTime = new Date().getTime();
-            const getTimerTime = () => new Date().getTime() - startTime;
-            const getTimeout = () => isWaitingForLogin ? 0 : Math.max(5000, timeout - getTimerTime());
-            return page.context().addCookies(this.webshotCookies)
-              .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-              .then(response => {
-                const itemIds: string[] = [];
-                const redirectionHandler = () =>
-                  acceptCookieConsent(page)
-                    .then(() => browserLogin(page))
-                    .catch((err: Error) => {
-                      if (err.name === 'TimeoutError') {
-                        logger.warn('navigation timed out, assuming login has failed');
-                        isWaitingForLogin = false;
-                      }
-                      throw err;
-                    })
-                    .then(() => browserSaveCookies(page))
-                    .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-                    .then(responseHandler);
-                const responseHandler = (res: typeof response): ReturnType<typeof response.json> => {
-                  if (res.status() !== 200) {
-                    throw customError('ResponseError')(
-                      `error navigating to user page, error was: ${res.status()} ${res.statusText()}`
-                    );
-                  }
-                  return res.json()
-                    .catch(redirectionHandler)
-                    .then((json: {[key: string]: {user: IgGraphQLUser}}) => {
-                      if (!json || !(json.graphql || json.data)?.user) {
-                        logger.warn('error parsing graphql response, returning empty object...');
-                        const data = {user: {edge_owner_to_timeline_media: {edges: []}} as IgGraphQLUser};
-                        return {graphql: data, data};
-                      }
-                      return json;
-                    });
-                };
-                const jsonHandler = ({user}: {user: IgGraphQLUser}): string[] | Promise<string[]> => {
-                  const pageInfo = user.edge_owner_to_timeline_media.page_info;
-                  for (const {node} of user.edge_owner_to_timeline_media.edges) {
-                    // exclude IGTV
-                    if (node.__typename === 'GraphVideo' && node.product_type === 'igtv') continue;
-                    // add post if ID is greater than target
-                    if (node.id && BigNumOps.compare(node.id, targetId) > 0) itemIds.push(node.id);
-                    // return of ID is equal to or smaller than target
-                    else return itemIds;
-                    // return after first addition if newly subscribed or restarted with resuming disabled
-                    if (Number(targetId) < 1) return itemIds;
-                  }
-                  // return if all IDs are greater than target but end of feed is reached
-                  if (!pageInfo?.has_next_page) return itemIds;
-                  // else, fetch next page using end_cursor
-                  logger.info('unable to find a smaller id than target, trying on next page...');
-                  url = graphqlLinkBuilder({userId: user.id, after: pageInfo.end_cursor});
-                  const nextPageDelay = this.webshotDelay * (0.4 + Math.random() * 0.1);
-                  timeout += nextPageDelay;
-                  return promisify(setTimeout)(nextPageDelay)
-                    .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-                    .then(responseHandler)
-                    .then(({data}: {data: {user: IgGraphQLUser}}) => jsonHandler(data));
-                };
-                return responseHandler(response)
-                  .then(({graphql}: {graphql: {user: IgGraphQLUser}}) => jsonHandler(graphql));
-              }).catch((err: Error) => {
-                if (err.name !== 'TimeoutError' && err.name !== 'ResponseError') throw err;
-                if (err.name === 'ResponseError') {
-                  logger.warn(`error while fetching posts by @${userName}: ${err.message}`);
-                } else logger.warn(`navigation timed out at ${getTimerTime()} ms`);
-                return [] as string[];
-              }).then(itemIds => promisify(setTimeout)(getTimeout()).then(() =>
-                itemIds.map(id => this.lazyGetMediaById(id))
-              ));
-          }).finally(() => { page.close(); });
-        });
-        setTimeout(this.work, this.workInterval * 1000 / this.lock.feed.length);
-      }
-    );
-  };
-
-  public queryUserMedia: (username: string, targetId?: string) => Promise<LazyMediaItem[]>;
-
-  public queryUser = (username: string) => this.client.user.searchExact(username)
-    .then(user => `${user.username}:${user.pk}`);
-
-  private workOnMedia = (
-    lazyMediaItems: LazyMediaItem[],
-    sendMedia: (msg: string, text: string, author: string) => void
-  ) => this.webshot(lazyMediaItems, sendMedia, this.webshotDelay);
-
-  public urlSegmentToId = urlSegmentToId;
-
-  public lazyGetMediaById = (id: string): LazyMediaItem => ({
-    pk: id,
-    item: () => this.client.media.info(id).then(media => {
-      const mediaItem = media.items[0] as MediaItem;
-      logger.debug(`api returned media post ${JSON.stringify(mediaItem)} for query id=${id}`);
-      return mediaItem;
-    }),
-  });
-
-  private getMedia = (segmentId: string, sender: (msg: string, text: string, author: string) => void) =>
-    this.workOnMedia([this.lazyGetMediaById(urlSegmentToId(segmentId))], sender);
-
-  private sendMedia = (source?: string, ...to: IChat[]) => (msg: string, text: string, author: string) => {
-    to.forEach(subscriber => {
-      logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
-      retryOnError(
-        () => this.bot.sendTo(subscriber, msg),
-        (_, count, terminate: (doNothing: Promise<void>) => void) => {
-          if (count <= maxTrials) {
-            logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-          } else {
-            logger.warn(`${count - 1} consecutive failures while sending message chain, trying plain text instead...`);
-            terminate(this.bot.sendTo(subscriber, author + text, true));
-          }
-        });
-    });
-  };
-
-  public get isInactiveTime() {
-    const timeToEpoch = (h = 0, m = 0) => new Date().setHours(h, m, 0, 0);
-    return this.inactiveHours
-      .map(rangeStr => ((start, end) => ({start, end}))(
-        ...rangeStr.split('-', 2).map(timeStr => timeToEpoch(...timeStr.split(':', 2).map(Number))) as [number, number?]
-      ))
-      .some(range => (now => now >= range.start && now < range.end)(Date.now()));
-  }
-
-  public work = () => {
-    const lock = this.lock;
-    if (this.workInterval < 1) this.workInterval = 1;
-    if (this.isInactiveTime || lock.feed.length === 0) {
-      setTimeout(this.work, this.workInterval * 1000 / lock.feed.length);
-      return;
-    }
-    lock.feed.forEach((feed, index) => {
-      if (!lock.threads[feed] ||
-        !lock.threads[feed].subscribers ||
-        lock.threads[feed].subscribers.length === 0) {
-        logger.warn(`nobody subscribes thread ${feed}, removing from feed`);
-        delete lock.threads[index];
-        lock.feed.splice(index, 1);
-        fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-      }
-    });
-    
-    const queuedFeeds = lock.feed.slice(0, (lock.workon + 1) || undefined).reverse();
-    chainPromises(Arr.chunk(queuedFeeds, 5).map((arr, i) =>
-      () => Promise.all(arr.map((currentFeed, j) => {
-        const workon = (queuedFeeds.length - 1) - (i * 5 + j);
-        fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-        const promiseDelay = this.workInterval * (Math.random() + j) * 250 / lock.feed.length;
-        const startTime = new Date().getTime();
-        const getTimerTime = () => new Date().getTime() - startTime;
-
-        const promise = promisify(setTimeout)(promiseDelay * 3).then(() => {
-          logger.info(`about to pull from feed #${workon}: ${currentFeed}`);
-          if (j === arr.length - 1) logger.info(`timeout for this batch job: ${Math.trunc(promiseDelay)} ms`);
-          const match = /https:\/\/www\.instagram\.com\/([^\/]+)/.exec(currentFeed);
-          if (!match) {
-            logger.error(`current feed "${currentFeed}" is invalid, please remove this feed manually`);
-            return [] as LazyMediaItem[];
-          }
-          return this.queryUserMedia(match[1], this.lock.threads[currentFeed].offset)
-            .catch((error: Error) => {
-              logger.error(`error scraping media off profile page of ${match[1]}, error: ${error}`);
-              return [] as LazyMediaItem[];
-            });
-        }).then((mediaItems: LazyMediaItem[]) => {
-          const currentThread = lock.threads[currentFeed];
-
-          const updateDate = () => currentThread.updatedAt = new Date().toString();
-          if (!mediaItems || mediaItems.length === 0) { updateDate(); return; }
-
-          const topOfFeed = mediaItems[0].pk;
-          const updateOffset = () => currentThread.offset = topOfFeed;
-
-          if (currentThread.offset === '-1') { updateOffset(); return; }
-
-          return this.workOnMedia(mediaItems, this.sendMedia(`thread ${currentFeed}`, ...currentThread.subscribers))
-            .then(updateDate).then(updateOffset);
-        }).then(() => {
-          lock.workon = workon - 1;
-          if (j === arr.length - 1) {
-            logger.info(`batch job #${workon}-${workon + j} completed after ${getTimerTime()} ms`);
-          }
-          fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-        });
-
-        return Promise.race([promise, isWaitingForLogin ? neverResolves() : promisify(setTimeout)(promiseDelay * 4)]);
-      }))
-    )).then(this.work);
-  };
-}

+ 0 - 337
src/webshot.ts

@@ -1,337 +0,0 @@
-import { writeFileSync } from 'fs';
-import { Readable } from 'stream';
-import { promisify } from 'util';
-
-import axios from 'axios';
-import * as CallableInstance from 'callable-instance';
-import { XmlEntities } from 'html-entities';
-import { PNG } from 'pngjs';
-import * as puppeteer from 'playwright';
-import * as sharp from 'sharp';
-import * as temp from 'temp';
-
-import { getLogger } from './loggers';
-import { Message } from './koishi';
-import { chainPromises } from './utils';
-import { linkBuilder, MediaItem, LazyMediaItem, WebshotHelpers } from './twitter';
-
-const xmlEntities = new XmlEntities();
-
-const ZHType = (type: string) => new class extends String {
-  public type = super.toString();
-  public toString = () => `[${super.toString()}]`;
-}(type);
-
-const typeInZH = {
-  photo: ZHType('图片'),
-  video: ZHType('视频'),
-};
-
-const logger = getLogger('webshot');
-
-export type Page = puppeteer.Page;
-export type Cookies = puppeteer.Cookie[];
-
-class Webshot extends CallableInstance<[LazyMediaItem[], (...args) => void, number], Promise<void>> {
-
-  private browser: puppeteer.Browser;
-  private mode: number;
-  private wsUrl: string;
-  private getCookies: () => Cookies;
-
-  constructor(
-    wsUrl: string, mode: number,
-    getCookies: () => Cookies,
-    onready: (doOnNewPage?: typeof Webshot.prototype.performOnNewPage) => void
-  ) {
-    super('webshot');
-    // tslint:disable-next-line: no-conditional-assignment
-    // eslint-disable-next-line no-cond-assign
-    if (this.mode = mode) {
-      if (onready) onready();
-    } else {
-      this.getCookies = getCookies;
-      this.wsUrl = wsUrl;
-      this.connect(() => onready && onready(this.performOnNewPage));
-    }
-  }
-
-  private connect = (onready?: (...args) => void): Promise<void> =>
-    axios.get<{[key in 'chromium' | 'firefox' | 'webkit']?: string}>(this.wsUrl)
-      .then(res => {
-        logger.info(`received websocket endpoint: ${JSON.stringify(res.data)}`);
-        const browserType = Object.keys(res.data)[0] as keyof typeof res.data;
-        return (puppeteer[browserType] as puppeteer.BrowserType<puppeteer.Browser>)
-          .connect({wsEndpoint: res.data[browserType]});
-      })
-      .then(browser => this.browser = browser)
-      .then(() => {
-        logger.info('launched puppeteer browser');
-        if (onready) return onready();
-      })
-      .catch(error => this.reconnect(error, onready));
-
-  private reconnect = (error, onready?: (...args) => void) => {
-    logger.error(`connection error, reason: ${error}`);
-    logger.warn('trying to reconnect in 2.5s...');
-    return promisify(setTimeout)(2500)
-      .then(() => this.connect(onready));
-  };
-
-  private performOnNewPage = <T>(action: (page: Page) => T | PromiseLike<T>, zoomFactor = 2, reconnectOnError = true) =>
-    this.browser.newPage({
-      bypassCSP: true,
-      deviceScaleFactor: zoomFactor,
-      locale: 'ja-JP',
-      timezoneId: 'Asia/Tokyo',
-      userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36',
-    }).then(action)
-      .catch(error => {
-        if (reconnectOnError) {
-          return this.reconnect(error)
-            .then((): Promise<T> => this.performOnNewPage(action, zoomFactor, reconnectOnError));
-        }
-        throw error;
-      });
-
-  private renderWebshot = (
-    url: string, height: number, webshotDelay: number,
-    ...morePostProcessings: ((page: Page) => Promise<any>)[]
-  ): Promise<string> => {
-    temp.track();
-    const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
-    const sharpToFile = (pic: sharp.Sharp) => new Promise<string>(resolve => {
-      const webshotTempFilePath = temp.path({suffix: '.jpg'});
-      pic.toFile(webshotTempFilePath).then(() => resolve(`file://${webshotTempFilePath}`));
-    });
-    const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
-      const width = 720;
-      const zoomFactor = 2;
-      logger.info(`shooting ${width}*${height} webshot for ${url}`);
-      this.performOnNewPage(
-        page => {
-          const startTime = new Date().getTime();
-          const getTimerTime = () => new Date().getTime() - startTime;
-          const getTimeout = () => WebshotHelpers.isWaitingForLogin ? 0 : Math.max(500, webshotDelay - getTimerTime());
-          page.setViewportSize({
-            width: width / zoomFactor,
-            height: height / zoomFactor,
-          }).then(() => page.context().addCookies(this.getCookies()))
-            .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-            .then(() => WebshotHelpers.handleCookieConsent(page))
-            .then(() =>
-              (<T>(next: () => Promise<T>) => Promise.race([
-                WebshotHelpers.handleLogin(page)
-                  .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
-                  .then(next),
-                next(),
-              ]))(() => promisify(setTimeout)(2000).then(() => 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()} ms`);
-              return null;
-            })
-            // hide header, footer, "more options" button, like and share count, avatar stories/live indicator
-            // person tags, carousel navigator, 2nd avatar, and comments; adjust layout for center alignment
-            .then(() => page.addStyleTag({content:
-              'nav,footer,main>*>*+*,header+div,header~div>div>div+div,main button,canvas,main section,main section+div>ul>:not(div),' +
-              'main section+div>ul>div [role="button"],header~div [tabindex="0"]>*>[tabindex="-1"]~div{display:none!important} ' +
-              'section+div{overflow:hidden} section+*>*{position:relative!important} article{border-bottom:1px solid!important} ' +
-              'main section+div>ul>div>li{padding:6px 2px 12px!important}',
-            }))
-            .then(() => page.addStyleTag({
-              content: '*{font-family:-apple-system,".Helvetica Neue DeskInterface",Hiragino Sans,Hiragino Sans GB,sans-serif!important}',
-            }))
-            // display absolute date and time and remove "comment limited" notice
-            .then(() => page.evaluate(() => {
-              let time: HTMLTimeElement;
-              time = document.querySelector('div>div>time');
-              if (time) time.parentElement.parentElement.style.display = 'none';
-              time = document.querySelector('main section~div>a>time');
-              if (time) {
-                time.innerHTML = time.title + ' ' + new Date(time.dateTime).toLocaleTimeString().slice(0, -3);
-                time.parentElement.parentElement.style.margin = '-24px 2px 12px';
-                const element = time.parentElement.parentElement.nextElementSibling as HTMLElement;
-                if (element) element.style.display = 'none';
-              }
-            }))
-            .then(() => chainPromises(morePostProcessings.map(func => () => func(page))))
-            .then(() => promisify(setTimeout)(getTimeout()))
-            .then(() => page.screenshot())
-            .then(screenshot => {
-              new PNG({
-                filterType: 4,
-                deflateLevel: 0,
-              }).on('parsed', function () {
-                const idx = (x: number, y: number) => (this.width * y + x) << 2;
-                let boundary: number = null;
-                for (let y = this.height - 1; y > this.height - 3840; 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 - 1;
-                    break;
-                  }
-                }
-                if (boundary !== null) {
-                  logger.info(`found boundary at ${boundary}, cropping image`);
-                  this.data = this.data.slice(0, idx(this.width, boundary));
-                  this.height = boundary;
-
-                  sharpToFile(jpeg(this.pack())).then(path => {
-                    logger.info(`finished webshot for ${url}`);
-                    resolve({path, boundary});
-                  });
-                } else if (height >= 8 * 1920) {
-                  logger.warn('too large, consider as a bug, returning');
-                  sharpToFile(jpeg(this.pack())).then(path => {
-                    resolve({path, boundary: 0});
-                  });
-                } else {
-                  logger.info('unable to find boundary, try shooting a larger image');
-                  resolve({path: '', boundary});
-                }
-              }).parse(screenshot);
-            })
-            .catch(err => {
-              if (err instanceof Error && err.name !== 'TimeoutError') throw err;
-              logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
-              resolve({path: '', boundary: 0});
-            })
-            .finally(() => { page.close(); });
-        },
-        zoomFactor, false)
-        .catch(reject);
-    });
-    return promise.then(data => {
-      if (data.boundary === null) {
-        return this.renderWebshot(url, height + 3840, webshotDelay, ...morePostProcessings);
-      } else return data.path;
-    }).catch(error => this.reconnect(error)
-      .then(() => this.renderWebshot(url, height, webshotDelay, ...morePostProcessings))
-    );
-  };
-
-  private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
-    logger.info(`fetching ${url}`);
-    axios({
-      method: 'get',
-      url,
-      responseType: 'arraybuffer',
-      timeout: 150000,
-    }).then(res => {
-      if (res.status === 200) {
-        logger.info(`successfully fetched ${url}`);
-        resolve(res.data);
-      } else {
-        logger.error(`failed to fetch ${url}: ${res.status}`);
-        reject();
-      }
-    }).catch (err => {
-      logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
-      reject();
-    });
-  }).then(data =>
-    (ext => {
-      const mediaTempFilePath = temp.path({suffix: `.${ext}`});
-      writeFileSync(mediaTempFilePath, Buffer.from(data));
-      const path = `file://${mediaTempFilePath}`;
-      switch (ext) {
-        case 'jpg':
-        case 'png':
-          return Message.Image(path);
-        case 'mp4':
-          return Message.Video(path);
-      }
-      logger.warn('unable to find MIME type of fetched media, failing this fetch');
-      throw Error();
-    })(/\/.*\.(.+?)\?/.exec(url)[1])
-  );
-
-  public webshot(
-    lazyMediaItems: LazyMediaItem[],
-    callback: (msgs: string, text: string, author: string) => void,
-    webshotDelay: number
-  ): Promise<void> {
-    let grandPromise = Promise.resolve();
-    // eslint-disable-next-line @typescript-eslint/no-misused-promises
-    lazyMediaItems.forEach(lazyItem => grandPromise = grandPromise.then(lazyItem.item).then(item => {
-      let promise = Promise.resolve();
-      promise = promise.then(() => {
-        logger.info(`working on ${item.user.username}/${item.code}`);
-      });
-      let messageChain = '';
-
-      // text processing
-      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 = linkBuilder({postUrlSegment: item.code});
-        promise = promise.then(() => this.renderWebshot(url, 3840, webshotDelay, page =>
-          // display full name
-          page.addStyleTag({content:
-            'header>div>div+div{font-size:12px; line-height:15px; padding-top:0!important}' +
-            `header>div>div+div::before{content:"${item.user.full_name}"; color:#8e8e8e; font-weight:bold}`,
-          })
-        ))
-          .then(fileurl => {
-            if (fileurl) return Message.Image(fileurl);
-            return author + text;
-          })
-          .then(msg => {
-            if (msg) messageChain += msg;
-          });
-      }
-      // fetch extra entities
-      const type = (mediaItem): keyof typeof typeInZH =>
-        (mediaItem as MediaItem).video_versions ? 'video' : 'photo';
-      const fetchBestCandidate =(
-        candidates: (Partial<typeof item.video_versions[0]> & typeof item.image_versions2.candidates[0])[],
-        mediaType: keyof typeof typeInZH
-      ) => {
-        const url = candidates
-          .sort((var1, var2) => var2.width + (var2?.type || 0) - var1.width - (var1?.type || 0))
-          .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 (item.carousel_media) {
-          return chainPromises(item.carousel_media.map(carouselItem =>
-            () => fetchBestCandidate(
-              (carouselItem as unknown as MediaItem).video_versions || 
-              carouselItem.image_versions2.candidates,
-              type(carouselItem)
-            )
-          ));
-        } else if (item.video_versions) {
-          return fetchBestCandidate(item.video_versions, type(item));
-        } else if (item.image_versions2) {
-          return fetchBestCandidate(item.image_versions2.candidates, type(item));
-        }
-      });
-      promise.then(() => {
-        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);
-      });
-      return promise;
-    }));
-    return grandPromise;
-  }
-}
-
-export default Webshot;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 14
src/webshot_test.js


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است