UNPKG

ptt-client

Version:

A Node client for fetching data from ptt.cc.

554 lines (486 loc) 14.7 kB
import EventEmitter from 'eventemitter3'; import sleep from 'sleep-promise'; import Terminal from 'terminal.js'; import Socket from '../../socket'; import { decode, encode, keymap as key, } from '../../utils'; import { getWidth, indexOfWidth, substrWidth, } from '../../utils/char'; import Config from '../../config'; import defaultConfig from './config'; import {Article, Board} from './model'; class Condition { private typeWord: string; private criteria: string; constructor(type: 'push'|'author'|'title', criteria: string) { switch (type) { case 'push': this.typeWord = 'Z'; break; case 'author': this.typeWord = 'a'; break; case 'title': this.typeWord = '/'; break; default: throw new Error(`Invalid condition: ${type}`); } this.criteria = criteria; } toSearchString(): string { return `${this.typeWord}${this.criteria}`; } } class Bot extends EventEmitter { static initialState = { connect: false, login: false, }; static forwardEvents = [ 'message', 'error', ]; searchCondition = { conditions: null, init: function() { this.conditions = []; }, add: function(type, criteria) { this.conditions.push(new Condition(type, criteria)); } }; private config: Config; private term: Terminal; private _state: any; private currentCharset: string; private socket: Socket; private preventIdleHandler: ReturnType<typeof setTimeout>; constructor(config?: Config) { super(); this.config = {...defaultConfig, ...config}; this.init(); } async init(): Promise<void> { const { config } = this; this.term = new Terminal(config.terminal); this._state = { ...Bot.initialState }; this.term.state.setMode('stringWidth', 'dbcs'); this.currentCharset = 'big5'; switch (config.protocol.toLowerCase()) { case 'websocket': case 'ws': case 'wss': break; case 'telnet': case 'ssh': default: throw new Error(`Invalid protocol: ${config.protocol}`); break; } const socket = new Socket(config); socket.connect(); Bot.forwardEvents.forEach(e => { socket.on(e, this.emit.bind(this, e)); }); socket .on('connect', (...args) => { this._state.connect = true; this.emit('connect', ...args); this.emit('stateChange', this.state); }) .on('disconnect', (closeEvent, ...args) => { this._state.connect = false; this.emit('disconnect', closeEvent, ...args); this.emit('stateChange', this.state); }) .on('message', (data) => { if (this.currentCharset !== this.config.charset && !this.state.login && decode(data, 'utf8').includes('登入中,請稍候...')) { this.currentCharset = this.config.charset; } const msg = decode(data, this.currentCharset); this.term.write(msg); this.emit('redraw', this.term.toString()); }) .on('error', (err) => { }); this.socket = socket; } get state(): any { return {...this._state}; } getLine = (n) => { return this.term.state.getLine(n); } async getLines() { const { getLine } = this; const lines = []; lines.push(getLine(0).str); let sentPgDown = false; while (!getLine(23).str.includes('100%') && !getLine(23).str.includes('此文章無內容')) { for (let i = 1; i < 23; i++) { lines.push(getLine(i).str); } await this.send(key.PgDown); sentPgDown = true; } const lastLine = lines[lines.length - 1]; for (let i = 0; i < 23; i++) { if (getLine(i).str === lastLine) { for (let j = i + 1; j < 23; j++) { lines.push(getLine(j).str); } break; } } while (lines.length > 0 && lines[lines.length - 1] === '') { lines.pop(); } if (sentPgDown) { await this.send(key.Home); } return lines; } send(msg: string): Promise<boolean> { if (this.config.preventIdleTimeout) { this.preventIdle(this.config.preventIdleTimeout); } return new Promise((resolve, reject) => { let autoResolveHandler; const cb = message => { clearTimeout(autoResolveHandler); resolve(true); }; if (this.state.connect) { if (msg.length > 0) { this.socket.send(encode(msg, this.currentCharset)); this.once('message', cb); autoResolveHandler = setTimeout(() => { this.removeListener('message', cb); resolve(false); }, this.config.timeout * 10); } else { console.warn(`Sending message with 0-length`); resolve(true); } } else { reject(); } }); } preventIdle(timeout: number): void { clearTimeout(this.preventIdleHandler); if (this.state.login) { this.preventIdleHandler = setTimeout(async () => { await this.send(key.CtrlU); await this.send(key.ArrowLeft); }, timeout * 1000); } } async login(username: string, password: string, kick: boolean= true): Promise<any> { if (this.state.login) { return; } username = username.replace(/,/g, ''); if (this.config.charset === 'utf8') { username += ','; } await this.send(`${username}${key.Enter}${password}${key.Enter}`); let ret; while ((ret = await this.checkLogin(kick)) === null) { await sleep(400); } if (ret) { const { _state: state } = this; state.login = true; state.position = { boardname: '', }; this.searchCondition.init(); this.emit('stateChange', this.state); } return ret; } async logout(): Promise<boolean> { if (!this.state.login) { return; } await this.send(`G${key.Enter}Y${key.Enter}`); this._state.login = false; this.emit('stateChange', this.state); this.send(key.Enter); return true; } private async checkLogin(kick: boolean): Promise<any> { const { getLine } = this; if (getLine(21).str.includes('密碼不對或無此帳號')) { this.emit('login.failed'); return false; } else if (getLine(23).str.includes('請稍後再試')) { this.emit('login.failed'); return false; } else if (getLine(22).str.includes('您想刪除其他重複登入的連線嗎')) { await this.send(`${key.Backspace}${kick ? 'y' : 'n'}${key.Enter}`); } else if (getLine(23).str.includes('請勿頻繁登入以免造成系統過度負荷')) { await this.send(`${key.Enter}`); } else if (getLine(23).str.includes('按任意鍵繼續')) { await this.send(`${key.Enter}`); } else if (getLine(23).str.includes('您要刪除以上錯誤嘗試的記錄嗎')) { await this.send(`${key.Backspace}y${key.Enter}`); } else if ((getLine(22).str + getLine(23).str).toLowerCase().includes('y/n')) { await this.send(`${key.Backspace}y${key.Enter}`); } else if (getLine(23).str.includes('我是')) { this.emit('login.success'); return true; } else { await this.send(`q`); } return null; } private checkArticleWithHeader(): boolean { const authorArea = substrWidth('dbcs', this.getLine(0).str, 0, 6).trim(); return authorArea === '作者'; } select(model) { return model.select(this); } /** * @deprecated */ setSearchCondition(type: string, criteria: string): void { this.searchCondition.add(type, criteria); } /** * @deprecated */ resetSearchCondition(): void { this.searchCondition.init(); } /** * @deprecated */ isSearchConditionSet(): boolean { return (this.searchCondition.conditions.length !== 0); } /** * @deprecated */ async getArticles(boardname: string, offset: number= 0): Promise<Article[]> { await this.enterBoard(boardname); if (this.isSearchConditionSet()) { const searchString = this.searchCondition.conditions.map(condition => condition.toSearchString()).join(key.Enter); await this.send(`${searchString}${key.Enter}`); } if (offset > 0) { offset = Math.max(offset - 9, 1); await this.send(`${key.End}${key.End}${offset}${key.Enter}`); } const { getLine } = this; const articles: Article[] = []; for (let i = 3; i <= 22; i++) { const line = getLine(i).str; const article = Article.fromLine(line); article.boardname = boardname; articles.push(article); } // fix id if (articles.length >= 2 && articles[0].id === 0) { for (let i = 1; i < articles.length; i++) { if (articles[i].id !== 0) { articles[0].id = articles[i].id - i; break; } } } for (let i = 1; i < articles.length; i++) { articles[i].id = articles[i - 1].id + 1; } await this.enterIndex(); return articles.reverse(); } /** * @deprecated */ async getArticle(boardname: string, id: number, article: Article = new Article()): Promise<Article> { await this.enterBoard(boardname); if (this.isSearchConditionSet()) { const searchString = this.searchCondition.conditions.map(condition => condition.toSearchString()).join(key.Enter); await this.send(`${searchString}${key.Enter}`); } const { getLine } = this; await this.send(`${id}${key.Enter}${key.Enter}`); const hasHeader = this.checkArticleWithHeader(); article.id = id; article.boardname = boardname; if (hasHeader) { article.author = substrWidth('dbcs', getLine(0).str, 7, 50).trim(); article.title = substrWidth('dbcs', getLine(1).str, 7 ).trim(); article.timestamp = substrWidth('dbcs', getLine(2).str, 7 ).trim(); } article.lines = await this.getLines(); await this.enterIndex(); return article; } /** * @deprecated */ async getFavorite(offsets: number|number[]= []) { if (typeof offsets === 'number') { offsets = [offsets]; } await this.enterFavorite(offsets); const { getLine } = this; const favorites: Board[] = []; while (true) { let stopLoop = false; for (let i = 3; i < 23; i++) { const line = getLine(i).str; if (line.trim() === '') { stopLoop = true; break; } const favorite = Board.fromLine(line); if (favorite.id !== favorites.length + 1) { stopLoop = true; break; } favorites.push(favorite); } if (stopLoop) { break; } await this.send(key.PgDown); } await this.enterIndex(); return favorites; } async getMails(offset: number= 0) { await this.enterMail(); if (offset > 0) { offset = Math.max(offset - 9, 1); await this.send(`${key.End}${key.End}${offset}${key.Enter}`); } const { getLine } = this; const mails = []; for (let i = 3; i <= 22; i++) { const line = getLine(i).str; const mail = { sn: +substrWidth('dbcs', line, 1, 5).trim(), date: substrWidth('dbcs', line, 9, 5).trim(), author: substrWidth('dbcs', line, 15, 12).trim(), status: substrWidth('dbcs', line, 30, 2).trim(), title: substrWidth('dbcs', line, 33 ).trim(), }; mails.push(mail); } await this.enterIndex(); return mails.reverse(); } async getMail(sn: number) { await this.enterMail(); const { getLine } = this; await this.send(`${sn}${key.Enter}${key.Enter}`); const hasHeader = this.checkArticleWithHeader(); const mail = { sn, author: '', title: '', timestamp: '', lines: [], }; if (this.checkArticleWithHeader()) { mail.author = substrWidth('dbcs', getLine(0).str, 7, 50).trim(); mail.title = substrWidth('dbcs', getLine(1).str, 7 ).trim(); mail.timestamp = substrWidth('dbcs', getLine(2).str, 7 ).trim(); } mail.lines = await this.getLines(); await this.enterIndex(); return mail; } async enterIndex(): Promise<boolean> { await this.send(`${key.ArrowLeft.repeat(10)}`); return true; } get currentBoardname(): string|undefined { const boardRe = /【(?!看板列表).*】.*《(?<boardname>.*)》/; const match = boardRe.exec(this.getLine(0).str); if (match) { return match.groups.boardname; } else { return void 0; } } /** * @deprecated */ enterBoard(boardname: string): Promise<boolean> { return this.enterBoardByName(boardname); } async enterBoardByName(boardname: string): Promise<boolean> { await this.send(`s${boardname}${key.Enter} ${key.Home}${key.End}`); if (this.currentBoardname.toLowerCase() === boardname.toLowerCase()) { this._state.position.boardname = this.currentBoardname; this.emit('stateChange', this.state); return true; } else { await this.enterIndex(); return false; } } async enterByOffset(offsets: number[]= []): Promise<boolean> { const { getLine } = this; let result = true; offsets.forEach(async offset => { if (offset === 0) { result = false; } if (offset < 0) { for (let i = 22; i >= 3; i--) { let lastOffset = substrWidth('dbcs', getLine(i).str, 3, 4).trim(); if (lastOffset.length > 0) { offset += +lastOffset + 1; break; } lastOffset = substrWidth('dbcs', getLine(i).str, 15, 2).trim(); if (lastOffset.length > 0) { offset += +lastOffset + 1; break; } } } if (offset < 0) { result = false; } if (!result) { return; } await this.send(`${offset}${key.Enter.repeat(2)} ${key.Home}${key.End}`); }); if (result) { this._state.position.boardname = this.currentBoardname; this.emit('stateChange', this.state); await this.send(key.Home); return true; } else { await this.enterIndex(); return false; } } async enterBoardByOffset(offsets: number[]= []): Promise<boolean> { await this.send(`C${key.Enter}`); return await this.enterByOffset(offsets); } async enterFavorite(offsets: number[]= []): Promise<boolean> { await this.send(`F${key.Enter}`); return await this.enterByOffset(offsets); } async enterMail(): Promise<boolean> { await this.send(`M${key.Enter}R${key.Enter}${key.Home}${key.End}`); return true; } } export default Bot;