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