浏览代码

more css tweak, 2FA, login/scraper timeout fixes

Mike L 3 年之前
父节点
当前提交
1b7b931a52
共有 7 个文件被更改,包括 182 次插入44 次删除
  1. 1 0
      config.example.json
  2. 7 0
      dist/main.js
  3. 78 17
      dist/twitter.js
  4. 4 5
      dist/webshot.js
  5. 8 0
      src/main.ts
  6. 80 17
      src/twitter.ts
  7. 4 5
      src/webshot.ts

+ 1 - 0
config.example.json

@@ -7,6 +7,7 @@
   "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,

+ 7 - 0
dist/main.js

@@ -73,6 +73,12 @@ optionalFields.forEach(key => {
         config[key] = `${config.ig_username}.${key.replace('_lockfile', '.lock')}`;
     }
 });
+(k => {
+    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];
+    }
+})('ig_2fa_code_receiver_port');
 loggers_1.setLogLevels(config.loglevel);
 let lock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
@@ -125,6 +131,7 @@ const qq = new koishi_1.default({
 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,

+ 78 - 17
dist/twitter.js

@@ -10,8 +10,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
 };
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.sendPost = exports.getPostOwner = exports.WebshotHelpers = exports.ScreenNameNormalizer = exports.SessionManager = exports.urlSegmentToId = exports.idToUrlSegment = exports.isValidUrlSegment = exports.parseLink = exports.linkBuilder = 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; } });
@@ -44,7 +47,7 @@ const linkBuilder = (config) => {
 };
 exports.linkBuilder = linkBuilder;
 class SessionManager {
-    constructor(client, file, credentials) {
+    constructor(client, file, credentials, codeServicePort) {
         this.init = () => {
             this.ig.state.generateDevice(this.username);
             this.ig.request.end$.subscribe(() => { this.save(); });
@@ -61,14 +64,55 @@ class SessionManager {
                     return Promise.resolve();
                 }
             }
-            else
-                return this.login();
+            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))
-            .then(() => new Promise(resolve => {
+            .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()));
+            process.nextTick(() => resolve(this.ig.simulate.postLoginFlow().then(() => user)));
         }));
         this.save = () => this.ig.state.serialize()
             .then((serialized) => {
@@ -78,6 +122,7 @@ class SessionManager {
         this.ig = client;
         this.lockfile = file;
         [this.username, this.password] = credentials;
+        this.codeServicePort = codeServicePort;
     }
 }
 exports.SessionManager = SessionManager;
@@ -102,6 +147,7 @@ exports.ScreenNameNormalizer = ScreenNameNormalizer;
 ScreenNameNormalizer.normalize = (username) => `${username.toLowerCase().replace(/^@/, '')}:`;
 let browserLogin = (page) => Promise.reject();
 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')
@@ -109,6 +155,7 @@ const acceptCookieConsent = (page) => page.click('button:has-text("すべて許
 exports.WebshotHelpers = {
     handleLogin: browserLogin,
     handleCookieConsent: acceptCookieConsent,
+    get isWaitingForLogin() { return isWaitingForLogin; },
 };
 let getPostOwner = (segmentId) => Promise.reject();
 exports.getPostOwner = getPostOwner;
@@ -153,10 +200,10 @@ class default_1 {
                     logger.debug(`pulling ${targetId !== '0' ? `feed ${url} up to ${targetId}` : `top of feed ${url}`}...`);
                     return doOnNewPage(newPage => {
                         page = newPage;
-                        let timeout = this.webshotDelay;
+                        let timeout = this.workInterval * 1000;
                         const startTime = new Date().getTime();
                         const getTimerTime = () => new Date().getTime() - startTime;
-                        const getTimeout = () => Math.max(500, timeout - getTimerTime());
+                        const getTimeout = () => isWaitingForLogin ? 0 : Math.max(90000, timeout - getTimerTime());
                         return page.context().addCookies(this.webshotCookies)
                             .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
                             .then(response => {
@@ -170,14 +217,16 @@ class default_1 {
                             .then(() => (next => Promise.race([
                             browserLogin(page)
                                 .catch((err) => {
-                                if (err.name === 'TimeoutError')
+                                if (err.name === 'TimeoutError') {
                                     logger.warn('navigation timed out, assuming login has failed');
+                                    isWaitingForLogin = false;
+                                }
                                 throw err;
                             })
                                 .then(() => browserSaveCookies(page))
                                 .then(() => page.goto(url)).then(next),
                             next(),
-                        ]))(() => page.waitForSelector('article', { timeout: getTimeout() }))).then(handle => {
+                        ]))(() => util_1.promisify(setTimeout)(2000).then(() => page.waitForSelector('article', { timeout: getTimeout() })))).then(handle => {
                             const postHandler = () => {
                                 const toId = (href) => { var _a; return instagram_id_to_url_segment_1.urlSegmentToInstagramId(((_a = /\/p\/(.*)\/$/.exec(href)) !== null && _a !== void 0 ? _a : [, ''])[1]); };
                                 if (targetId === '0') {
@@ -200,7 +249,7 @@ class default_1 {
                             return postHandler().then(itemIds => {
                                 if (itemIds)
                                     return itemIds;
-                                timeout += this.webshotDelay / 2;
+                                timeout += this.workInterval * 500;
                                 return handle.$$('a')
                                     .then(as => { as.pop().scrollIntoViewIfNeeded(); return as.length + 1; })
                                     .then(loadedCount => page.waitForFunction(count => document.querySelectorAll('article a').length > count, loadedCount))
@@ -327,7 +376,7 @@ class default_1 {
                 logger.warn(`invalid socks proxy url: ${opt.proxyUrl}, ignoring`);
             }
         }
-        this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
+        this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials, opt.codeServicePort);
         this.lockfile = opt.lockfile;
         this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
         this.lock = opt.lock;
@@ -345,11 +394,21 @@ class default_1 {
             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])
-            .then(() => logger.warn('blocked by login dialog, trying to log in manually...'))
-            .then(() => page.fill('input[name="password"]', opt.credentials[1]))
-            .then(() => page.click('button[type="submit"]'))
-            .then(() => page.click('button:has-text("情報を保存")'));
+        browserLogin = page => page.fill('input[name="username"]', opt.credentials[0], { timeout: 0 })
+            .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);
+            }),
+            page.waitForResponse(res => res.status() === 429, { timeout: 0 })
+                .then(() => { logger.error('fatal error: login restricted: code 429, exiting'); process.exit(1); }),
+            next(),
+        ]))(() => page.click('button:has-text("情報を保存")', { timeout: 0 }).then(() => { isWaitingForLogin = false; })));
         browserSaveCookies = page => page.context().cookies()
             .then(cookies => {
             this.webshotCookies = cookies;
@@ -360,8 +419,10 @@ class default_1 {
             .then(() => page.waitForSelector('img[data-testid="user-avatar"]', { timeout: this.webshotDelay }))
             .then(() => browserSaveCookies(page))
             .catch((err) => {
-            if (err.name === 'TimeoutError')
+            if (err.name === 'TimeoutError') {
                 logger.warn('navigation timed out, assuming login has failed');
+                isWaitingForLogin = false;
+            }
             throw err;
         });
         ScreenNameNormalizer._queryUser = this.queryUser;

+ 4 - 5
dist/webshot.js

@@ -77,7 +77,7 @@ class Webshot extends CallableInstance {
                 this.performOnNewPage(page => {
                     const startTime = new Date().getTime();
                     const getTimerTime = () => new Date().getTime() - startTime;
-                    const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
+                    const getTimeout = () => twitter_1.WebshotHelpers.isWaitingForLogin ? 0 : Math.max(500, webshotDelay - getTimerTime());
                     page.setViewportSize({
                         width: width / zoomFactor,
                         height: height / zoomFactor,
@@ -89,7 +89,7 @@ class Webshot extends CallableInstance {
                             .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
                             .then(next),
                         next(),
-                    ]))(() => page.waitForSelector('article', { timeout: getTimeout() })))
+                    ]))(() => util_1.promisify(setTimeout)(2000).then(() => page.waitForSelector('article', { timeout: getTimeout() }))))
                         .catch((err) => {
                         if (err.name !== 'TimeoutError')
                             throw err;
@@ -230,9 +230,8 @@ class Webshot extends CallableInstance {
                 messageChain += (author + xmlEntities.decode(text));
             if (this.mode === 0) {
                 const url = twitter_1.linkBuilder({ postUrlSegment: item.code });
-                promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay, page => page.addStyleTag({ content: 'header div div div *{text-align:left!important} header div div div a::after{' +
-                        `content:"${item.user.full_name}";` +
-                        'display:block; font-size:smaller; color: #8e8e8e; line-height:1.25}',
+                promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay, page => page.addStyleTag({ content: 'header>div>div+div{font-size:smaller; line-height:calc(4/3)}' +
+                        `header>div>div+div::before{content:"${item.user.full_name}"; color:#8e8e8e; font-weight:bold}`,
                 })))
                     .then(fileurl => {
                     if (fileurl)

+ 8 - 0
src/main.ts

@@ -87,6 +87,13 @@ optionalFields.forEach(key => {
   }
 });
 
+(k => {
+  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];
+  }
+})('ig_2fa_code_receiver_port');
+
 setLogLevels(config.loglevel);
 
 let lock: ILock;
@@ -140,6 +147,7 @@ const qq = new QQBot({
 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,

+ 80 - 17
src/twitter.ts

@@ -1,5 +1,8 @@
+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 {
@@ -8,7 +11,7 @@ import {
 } from 'instagram-id-to-url-segment';
 import {
   IgApiClient,
-  IgClientError, IgExactUserNotFoundError, IgResponseError,
+  IgClientError, IgExactUserNotFoundError, IgLoginTwoFactorRequiredError, IgResponseError,
   MediaInfoResponseItemsItem, UserFeedResponseItemsItem
 } from 'instagram-private-api';
 import { SocksProxyAgent } from 'socks-proxy-agent';
@@ -41,6 +44,7 @@ export { linkBuilder, parseLink, isValidUrlSegment, idToUrlSegment, urlSegmentTo
 interface IWorkerOption {
   sessionLockfile: string;
   credentials: [string, string];
+  codeServicePort: number;
   proxyUrl: string;
   lock: ILock;
   lockfile: string;
@@ -57,11 +61,13 @@ export class SessionManager {
   private username: string;
   private password: string;
   private lockfile: string;
+  private codeServicePort: number;
 
-  constructor(client: IgApiClient, file: string, credentials: [string, string]) {
+  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 = () => {
@@ -78,15 +84,57 @@ export class SessionManager {
         logger.error(`failed to load client session cookies from file ${this.lockfile}: `, err);
         return Promise.resolve();
       }
-    } else return this.login();
+    } 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))
-      .then(() => new Promise(resolve => {
+      .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()));
+        process.nextTick(() => resolve(this.ig.simulate.postLoginFlow().then(() => user)));
       }));
 
   public save = () =>
@@ -123,6 +171,8 @@ let browserLogin = (page: Page): Promise<void> => Promise.reject();
 
 let browserSaveCookies = browserLogin;
 
+let isWaitingForLogin = false;
+
 const acceptCookieConsent = (page: Page) =>
   page.click('button:has-text("すべて許可")', { timeout: 5000 })
     .then(() => logger.info('accepted cookie consent'))
@@ -131,6 +181,7 @@ const acceptCookieConsent = (page: Page) =>
 export const WebshotHelpers = {
   handleLogin: browserLogin,
   handleCookieConsent: acceptCookieConsent,
+  get isWaitingForLogin() { return isWaitingForLogin; },
 };
 
 export let getPostOwner = (segmentId: string): Promise<string> => Promise.reject();
@@ -208,7 +259,7 @@ export default class {
         logger.warn(`invalid socks proxy url: ${opt.proxyUrl}, ignoring`);
       }
     }
-    this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials);
+    this.session = new SessionManager(this.client, opt.sessionLockfile, opt.credentials, opt.codeServicePort);
     this.lockfile = opt.lockfile;
     this.webshotCookiesLockfile = opt.webshotCookiesLockfile;
     this.lock = opt.lock;
@@ -231,11 +282,23 @@ export default class {
     }
 
     browserLogin = page =>
-      page.fill('input[name="username"]', opt.credentials[0])
-        .then(() => logger.warn('blocked by login dialog, trying to log in manually...'))
-        .then(() => page.fill('input[name="password"]', opt.credentials[1]))
-        .then(() => page.click('button[type="submit"]'))
-        .then(() => page.click('button:has-text("情報を保存")'));
+      page.fill('input[name="username"]', opt.credentials[0], {timeout: 0})
+        .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);
+            }),
+            page.waitForResponse(res => res.status() === 429, {timeout: 0})
+              .then(() => { logger.error('fatal error: login restricted: code 429, exiting'); process.exit(1); }),
+            next(),
+          ]))(() => page.click('button:has-text("情報を保存")', {timeout: 0}).then(() => { isWaitingForLogin = false; }))
+        );
     browserSaveCookies = page =>
       page.context().cookies()
         .then(cookies => {
@@ -248,7 +311,7 @@ export default class {
         .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');
+          if (err.name === 'TimeoutError') { logger.warn('navigation timed out, assuming login has failed'); isWaitingForLogin = false; }
           throw err;
         });
     ScreenNameNormalizer._queryUser = this.queryUser;
@@ -282,10 +345,10 @@ export default class {
           logger.debug(`pulling ${targetId !== '0' ? `feed ${url} up to ${targetId}` : `top of feed ${url}`}...`);
           return doOnNewPage(newPage => {
             page = newPage;
-            let timeout = this.webshotDelay;
+            let timeout = this.workInterval * 1000;
             const startTime = new Date().getTime();
             const getTimerTime = () => new Date().getTime() - startTime;
-            const getTimeout = () => Math.max(500, timeout - getTimerTime());
+            const getTimeout = () => isWaitingForLogin ? 0 : Math.max(90000, timeout - getTimerTime());
             return page.context().addCookies(this.webshotCookies)
               .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
               .then(response => {
@@ -302,13 +365,13 @@ export default class {
                 (next => Promise.race([
                   browserLogin(page)
                     .catch((err: Error) => {
-                      if (err.name === 'TimeoutError') logger.warn('navigation timed out, assuming login has failed');
+                      if (err.name === 'TimeoutError') { logger.warn('navigation timed out, assuming login has failed'); isWaitingForLogin = false; }
                       throw err;
                     })
                     .then(() => browserSaveCookies(page))
                     .then(() => page.goto(url)).then(next),
                   next(),
-                ]))(() => page.waitForSelector('article', { timeout: getTimeout() }))
+                ]))(() => promisify(setTimeout)(2000).then(() => page.waitForSelector('article', {timeout: getTimeout()})))
               ).then(handle => {
                 const postHandler = () => {
                   const toId = (href: string) => urlSegmentToId((/\/p\/(.*)\/$/.exec(href) ?? [,''])[1]);
@@ -333,7 +396,7 @@ export default class {
                 };
                 return postHandler().then(itemIds => {
                   if (itemIds) return itemIds;
-                  timeout += this.webshotDelay / 2;
+                  timeout += this.workInterval * 500;
                   return handle.$$('a')
                     .then(as => { as.pop().scrollIntoViewIfNeeded(); return as.length + 1; })
                     .then(loadedCount => page.waitForFunction(count =>

+ 4 - 5
src/webshot.ts

@@ -112,7 +112,7 @@ class Webshot extends CallableInstance<[LazyMediaItem[], (...args) => void, numb
         page => {
           const startTime = new Date().getTime();
           const getTimerTime = () => new Date().getTime() - startTime;
-          const getTimeout = () => Math.max(500, webshotDelay - getTimerTime());
+          const getTimeout = () => WebshotHelpers.isWaitingForLogin ? 0 : Math.max(500, webshotDelay - getTimerTime());
           page.setViewportSize({
             width: width / zoomFactor,
             height: height / zoomFactor,
@@ -125,7 +125,7 @@ class Webshot extends CallableInstance<[LazyMediaItem[], (...args) => void, numb
                   .then(() => page.goto(url, {waitUntil: 'load', timeout: getTimeout()}))
                   .then(next),
                 next(),
-              ]))(() => page.waitForSelector('article', {timeout: getTimeout()}))
+              ]))(() => promisify(setTimeout)(2000).then(() => page.waitForSelector('article', {timeout: getTimeout()})))
             )
             .catch((err: Error): Promise<puppeteer.ElementHandle<Element> | null> => {
               if (err.name !== 'TimeoutError') throw err;
@@ -272,9 +272,8 @@ class Webshot extends CallableInstance<[LazyMediaItem[], (...args) => void, numb
         promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay, page =>
           // display full name
           page.addStyleTag({content:
-            'header div div div *{text-align:left!important} header div div div a::after{' +
-            `content:"${item.user.full_name}";` +
-            'display:block; font-size:smaller; color: #8e8e8e; line-height:1.25}',
+            'header>div>div+div{font-size:smaller; line-height:calc(4/3)}' +
+            `header>div>div+div::before{content:"${item.user.full_name}"; color:#8e8e8e; font-weight:bold}`,
           })
         ))
           .then(fileurl => {