wiki.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import axios from 'axios';
  2. import fetchCookie = require('fetch-cookie');
  3. import { writeFileSync } from 'fs';
  4. import { MWBot, WikiError } from 'mediawiki2';
  5. import nodeFetch from 'node-fetch';
  6. import { firefox } from 'playwright';
  7. import { CookieJar } from 'tough-cookie';
  8. import gifski from './gifski';
  9. import { getLogger } from './loggers';
  10. import { Tweet, processTweetBody } from './twitter';
  11. const logger = getLogger('wiki');
  12. const baseUrl = 'https://wiki.biligame.com/idolypride';
  13. export default class {
  14. private bot: MWBot;
  15. private lock: ILock;
  16. constructor(lock: ILock) {
  17. this.bot = new MWBot(`${baseUrl}/api.php`);
  18. this.lock = lock;
  19. }
  20. public login = (sessdata: string) =>
  21. firefox.launch().then(browser => {
  22. const jar = (this.bot as any).cookieJar as CookieJar;
  23. return browser.newPage().then(page =>
  24. page.context().addCookies([{
  25. name: 'SESSDATA',
  26. value: sessdata,
  27. domain: '.biligame.com',
  28. path: '/',
  29. }])
  30. .then(() => page.route('**/*.{png,jpg,jpeg,gif}', route => route.abort()))
  31. .then(() => page.route('*://*.baidu.com/**', route => route.abort()))
  32. .then(() => page.goto(`${baseUrl}/index.php?curid=2`, {waitUntil: 'networkidle'}))
  33. .then(() => { logger.info('logging in via browser...'); return page.context().cookies(); })
  34. .then(cookies => {
  35. const uidIndex = cookies.findIndex(cookie => cookie.name === 'gamecenter_wiki_UserName');
  36. if (!uidIndex) throw new Error('auth error');
  37. return Promise.all(cookies.map(({name, value, domain, path}) =>
  38. jar.setCookie(`${name}=${value}; Domain=${domain}; Path=${path}`, baseUrl)
  39. )).then(() => cookies[uidIndex].value)
  40. })
  41. .then(uid => {
  42. logger.info(`finished logging in via browser, wiki username: ${uid}`);
  43. this.bot.fetch = (fetchCookie as any)(nodeFetch, jar);
  44. return browser.close();
  45. })
  46. .catch((err: Error) => browser.close().then(() => {
  47. logger.fatal(`error logging in via browser, error: ${err}`);
  48. process.exit(0);
  49. }))
  50. );
  51. });
  52. private fetchMedia = (url: string): Promise<string> => new Promise<ArrayBuffer>((resolve, reject) => {
  53. logger.info(`fetching ${url}`);
  54. const fetch = () => axios({
  55. method: 'get',
  56. url,
  57. responseType: 'arraybuffer',
  58. timeout: 150000,
  59. }).then(res => {
  60. if (res.status === 200) {
  61. logger.info(`successfully fetched ${url}`);
  62. resolve(res.data);
  63. } else {
  64. logger.error(`failed to fetch ${url}: ${res.status}`);
  65. reject();
  66. }
  67. }).catch(err => {
  68. logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
  69. logger.info(`trying to fetch ${url} again...`);
  70. fetch();
  71. });
  72. fetch();
  73. }).then(data =>
  74. (([_, filename, ext]) => {
  75. if (ext) {
  76. const mediaFileName = `${filename}.${ext}`;
  77. writeFileSync(mediaFileName, Buffer.from(data));
  78. return (ext === 'mp4' ?
  79. gifski(mediaFileName) :
  80. Promise.resolve(mediaFileName)
  81. );
  82. }
  83. logger.warn('unable to find MIME type of fetched media, failing this fetch');
  84. throw Error();
  85. })(/([^/]*)\?format=([a-z]+)&/.exec(url) ?? /.*\/([^/]*)\.([^?]+)/.exec(url))
  86. );
  87. private uploadMediaItems = (tweet: Tweet, fileNamePrefix: string, indexOffset = 0) => {
  88. const mediaItems: Promise<string>[] = [];
  89. if (tweet.extended_entities) {
  90. tweet.extended_entities.media.forEach((media, index) => {
  91. let url;
  92. if (media.type === 'photo') {
  93. url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';
  94. } else {
  95. url = media.video_info.variants
  96. .filter(variant => variant.bitrate !== undefined)
  97. .sort((var1, var2) => var2.bitrate - var1.bitrate)
  98. .map(variant => variant.url)[0]; // largest video
  99. }
  100. const mediaPromise = this.fetchMedia(url)
  101. .then(mediaFileName => {
  102. const filename = `${fileNamePrefix}${indexOffset + index + 1}.${mediaFileName.split('.')[1]}`;
  103. logger.info(`uploading ${url} as ${filename}...`);
  104. return this.bot.simpleUpload({
  105. file: mediaFileName,
  106. filename,
  107. })
  108. .then(() => filename)
  109. .catch(error => {
  110. if (error instanceof WikiError && error.data.result === 'Warning') {
  111. const {duplicate} = error.data.warnings;
  112. if (duplicate) return duplicate[0];
  113. } else throw error;
  114. })
  115. });
  116. mediaItems.push(mediaPromise);
  117. });
  118. }
  119. return Promise.all(mediaItems);
  120. };
  121. public appendMedia = (tweet: Tweet, genre: string, indexOffset: number): Promise<WikiEditResult> => {
  122. const {pageTitle} = processTweetBody(tweet);
  123. return this.uploadMediaItems(tweet, `公告-${genre}-${pageTitle}-`, indexOffset)
  124. .then(fileNames => {
  125. logger.info(`updating page 公告/${pageTitle}...`);
  126. return this.bot.edit({
  127. title: `公告/${pageTitle}`,
  128. appendtext: `${fileNames.map(fileName => `[[文件:${fileName}|无框|左]]\n`).join('')}`,
  129. bot: true,
  130. notminor: true,
  131. nocreate: true,
  132. })
  133. .then(({new: isNewPost, newtimestamp, pageid, result, title}) => ({
  134. pageid,
  135. title,
  136. new: isNewPost,
  137. mediafiles: fileNames,
  138. result,
  139. timestamp: new Date(newtimestamp).toString(),
  140. }))
  141. .catch(error => {
  142. logger.error(`error updating page, error: ${error}`);
  143. return {
  144. pageid: undefined as number,
  145. title: `公告/${pageTitle}`,
  146. new: undefined as boolean,
  147. mediafiles: [],
  148. result: 'Failed',
  149. timestamp: undefined as string,
  150. };
  151. });
  152. });
  153. };
  154. public post = (tweet: Tweet, genre: string): Promise<WikiEditResult> => {
  155. const {title, body, pageTitle, date} = processTweetBody(tweet);
  156. const sameTitleAction = this.lock.lastActions.find(action => action.title === title);
  157. if (sameTitleAction) return this.appendMedia(tweet, genre, sameTitleAction.mediafiles.length);
  158. return this.uploadMediaItems(tweet, `公告-${genre}-${pageTitle}-`)
  159. .then(fileNames => {
  160. logger.info(`creating page 公告/${pageTitle}...`);
  161. return this.bot.edit({
  162. title: `公告/${pageTitle}`,
  163. basetimestamp: new Date(),
  164. text: `{{文章戳
  165. |文章上级页面=公告
  166. |子类别=${genre}
  167. |时间=${date}
  168. |作者=IDOLY PRIDE
  169. |是否原创=否
  170. |来源=[https://twitter.com/idolypride IDOLY PRIDE]
  171. |原文地址=[https://twitter.com/idolypride/status/${tweet.id_str} ${pageTitle}]
  172. }}
  173. ====${title}====
  174. <poem>
  175. ${body}
  176. </poem>
  177. ${fileNames.map(fileName => `[[文件:${fileName}|无框|左]]`).join('\n')}
  178. `,
  179. bot: true,
  180. notminor: true,
  181. createonly: true,
  182. })
  183. .then(({new: isNewPost, newtimestamp, pageid, result, title}) => ({
  184. pageid,
  185. title,
  186. new: isNewPost,
  187. mediafiles: fileNames,
  188. result,
  189. timestamp: new Date(newtimestamp).toString(),
  190. }))
  191. .catch(error => {
  192. logger.error(`error creating page, error: ${error}`);
  193. return {
  194. pageid: undefined as number,
  195. title: `公告/${pageTitle}`,
  196. new: undefined as boolean,
  197. mediafiles: [],
  198. result: 'Failed',
  199. timestamp: undefined as string,
  200. };
  201. });
  202. });
  203. };
  204. }