wiki.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import { inspect } from 'util';
  2. import { createRequire } from 'module';
  3. const require = createRequire(import.meta.url);
  4. const {defaultSettings, wikiProjects} = require('./default.json');
  5. const wikimediaSites = [
  6. 'wikipedia.org',
  7. 'mediawiki.org',
  8. 'wikimedia.org',
  9. 'wiktionary.org',
  10. 'wikibooks.org',
  11. 'wikisource.org',
  12. 'wikidata.org',
  13. 'wikiversity.org',
  14. 'wikiquote.org',
  15. 'wikinews.org',
  16. 'wikivoyage.org'
  17. ];
  18. const urlSpaceReplacement = {
  19. 'https://www.wikihow.com/': '-',
  20. 'https://wikihow.com/': '-'
  21. }
  22. /**
  23. * A wiki.
  24. * @class Wiki
  25. */
  26. export default class Wiki extends URL {
  27. /**
  28. * Creates a new wiki.
  29. * @param {String|URL|Wiki} [wiki] - The wiki script path.
  30. * @param {String|URL|Wiki} [base] - The base for the wiki.
  31. * @constructs Wiki
  32. */
  33. constructor(wiki = defaultSettings.wiki, base = defaultSettings.wiki) {
  34. super(wiki, base);
  35. this.protocol = 'https';
  36. let articlepath = this.pathname + 'index.php?title=$1';
  37. if ( this.isFandom() ) articlepath = this.pathname + 'wiki/$1';
  38. this.gamepedia = this.hostname.endsWith( '.gamepedia.com' );
  39. if ( this.isGamepedia() ) articlepath = '/$1';
  40. let project = wikiProjects.find( project => this.hostname.endsWith( project.name ) );
  41. if ( project ) {
  42. let regex = ( this.host + this.pathname ).match( new RegExp( '^' + project.regex + project.scriptPath + '$' ) );
  43. if ( regex ) articlepath = 'https://' + regex[1] + project.articlePath + '$1';
  44. }
  45. this.articlepath = articlepath;
  46. this.mainpage = '';
  47. this.mainpageisdomainroot = false;
  48. this.miraheze = this.hostname.endsWith( '.miraheze.org' );
  49. this.wikimedia = wikimediaSites.includes( this.hostname.split('.').slice(-2).join('.') );
  50. this.centralauth = ( ( this.isWikimedia() || this.isMiraheze() ) ? 'CentralAuth' : 'local' );
  51. this.oauth2 = Wiki.oauthSites.includes( this.href );
  52. this.spaceReplacement = ( urlSpaceReplacement.hasOwnProperty(this.href) ? urlSpaceReplacement[this.href] : '_' );
  53. }
  54. /**
  55. * @type {String}
  56. */
  57. get articlepath() {
  58. return this.articleURL.pathname + this.articleURL.search;
  59. }
  60. set articlepath(path) {
  61. this.articleURL = new articleURL(path, this);
  62. }
  63. /**
  64. * @type {String}
  65. */
  66. get mainpage() {
  67. return this.articleURL.mainpage;
  68. }
  69. set mainpage(title) {
  70. this.articleURL.mainpage = title;
  71. }
  72. /**
  73. * Updates the wiki url.
  74. * @param {Object} siteinfo - Siteinfo from the wiki API.
  75. * @param {String} siteinfo.servername - Hostname of the wiki.
  76. * @param {String} siteinfo.scriptpath - Scriptpath of the wiki.
  77. * @param {String} siteinfo.articlepath - Articlepath of the wiki.
  78. * @param {String} siteinfo.mainpage - Main page of the wiki.
  79. * @param {String} siteinfo.centralidlookupprovider - Central auth of the wiki.
  80. * @param {String} siteinfo.logo - Logo of the wiki.
  81. * @param {String} [siteinfo.gamepedia] - If the wiki is a Gamepedia wiki.
  82. * @returns {Wiki}
  83. */
  84. updateWiki({servername, scriptpath, articlepath, mainpage, mainpageisdomainroot, centralidlookupprovider, logo, gamepedia = 'false'}) {
  85. this.hostname = servername;
  86. this.pathname = scriptpath + '/';
  87. this.articlepath = articlepath;
  88. this.mainpage = mainpage;
  89. this.mainpageisdomainroot = ( mainpageisdomainroot !== undefined );
  90. this.centralauth = centralidlookupprovider;
  91. this.miraheze = /^(?:https?:)?\/\/static\.miraheze\.org\//.test(logo);
  92. this.gamepedia = ( gamepedia === 'true' ? true : this.hostname.endsWith( '.gamepedia.com' ) );
  93. this.wikimedia = wikimediaSites.includes( this.hostname.split('.').slice(-2).join('.') );
  94. this.oauth2 = Wiki.oauthSites.includes( this.href );
  95. this.spaceReplacement = ( urlSpaceReplacement.hasOwnProperty(this.href) ? urlSpaceReplacement[this.href] : this.spaceReplacement );
  96. return this;
  97. }
  98. /**
  99. * Check for a Fandom wiki.
  100. * @param {Boolean} [includeGP] - If Gamepedia wikis are included.
  101. * @returns {Boolean}
  102. */
  103. isFandom(includeGP = true) {
  104. return ( this.hostname.endsWith( '.fandom.com' ) || this.hostname.endsWith( '.wikia.org' )
  105. || ( includeGP && this.isGamepedia() ) );
  106. }
  107. /**
  108. * Check for a Gamepedia wiki.
  109. * @returns {Boolean}
  110. */
  111. isGamepedia() {
  112. return this.gamepedia;
  113. }
  114. /**
  115. * Check for a Miraheze wiki.
  116. * @returns {Boolean}
  117. */
  118. isMiraheze() {
  119. return this.miraheze;
  120. }
  121. /**
  122. * Check for a WikiMedia wiki.
  123. * @returns {Boolean}
  124. */
  125. isWikimedia() {
  126. return this.wikimedia;
  127. }
  128. /**
  129. * Check for CentralAuth.
  130. * @returns {Boolean}
  131. */
  132. hasCentralAuth() {
  133. return this.centralauth === 'CentralAuth';
  134. }
  135. /**
  136. * Check for OAuth2.
  137. * @returns {Boolean}
  138. */
  139. hasOAuth2() {
  140. return ( this.isWikimedia() || this.isMiraheze() || this.oauth2 );
  141. }
  142. /**
  143. * Check if a wiki is missing.
  144. * @param {String} [message] - Error message or response url.
  145. * @param {Number} [statusCode] - Status code of the response.
  146. * @returns {Boolean}
  147. */
  148. noWiki(message = '', statusCode = 0) {
  149. if ( statusCode === 410 || statusCode === 404 ) return true;
  150. if ( !this.isFandom() ) return false;
  151. if ( this.hostname.startsWith( 'www.' ) || message.startsWith( 'https://www.' ) ) return true;
  152. return [
  153. 'https://community.fandom.com/wiki/Community_Central:Not_a_valid_community?from=' + this.hostname,
  154. this + 'language-wikis'
  155. ].includes( message.replace( /Unexpected token < in JSON at position 0 in "([^ ]+)"/, '$1' ) );
  156. }
  157. /**
  158. * Get a page link.
  159. * @param {String} [title] - Name of the page.
  160. * @param {URLSearchParams} [querystring] - Query arguments of the page.
  161. * @param {String} [fragment] - Fragment of the page.
  162. * @param {Boolean} [isMarkdown] - Use the link in markdown.
  163. * @returns {String}
  164. */
  165. toLink(title = '', querystring = '', fragment = '', isMarkdown = false) {
  166. querystring = new URLSearchParams(querystring);
  167. if ( !querystring.toString().length ) {
  168. title = ( title || this.mainpage );
  169. if ( this.mainpageisdomainroot && title === this.mainpage ) return this.origin + '/' + Wiki.toSection(fragment, true, this.spaceReplacement);
  170. }
  171. title = title.replace( / /g, this.spaceReplacement ).replace( /%/g, '%2525' );
  172. let link = new URL(this.articleURL);
  173. link.pathname = link.pathname.replace( '$1', title.replace( /\\/g, '%5C' ) );
  174. link.searchParams.forEach( (value, name, searchParams) => {
  175. if ( value.includes( '$1' ) ) {
  176. if ( !title ) searchParams.delete(name);
  177. else searchParams.set(name, value.replace( '$1', title ));
  178. }
  179. } );
  180. querystring.forEach( (value, name) => {
  181. link.searchParams.append(name, value);
  182. } );
  183. let output = decodeURI( link ).replace( /\\/g, '%5C' ).replace( /@(here|everyone)/g, '%40$1' ) + Wiki.toSection(fragment, true, this.spaceReplacement);
  184. if ( isMarkdown ) return output.replace( /\(/g, '%28' ).replace( /\)/g, '%29' );
  185. else return output;
  186. }
  187. /**
  188. * Encode a page title.
  189. * @param {String} [title] - Title of the page.
  190. * @param {String} [spaceReplacement] - The url replacement for spaces.
  191. * @returns {String}
  192. * @static
  193. */
  194. static toTitle(title = '', spaceReplacement = '_') {
  195. return title.replace( / /g, spaceReplacement ).replace( /[?&%\\]/g, (match) => {
  196. return '%' + match.charCodeAt().toString(16).toUpperCase();
  197. } ).replace( /@(here|everyone)/g, '%40$1' ).replace( /[()]/g, '\\$&' );
  198. };
  199. /**
  200. * Encode a link section.
  201. * @param {String} [fragment] - Fragment of the page.
  202. * @param {Boolean} [simpleEncoding] - Don't fully encode the anchor.
  203. * @param {String} [spaceReplacement] - The url replacement for spaces.
  204. * @returns {String}
  205. * @static
  206. */
  207. static toSection(fragment = '', simpleEncoding = true, spaceReplacement = '_') {
  208. if ( !fragment ) return '';
  209. fragment = fragment.replace( / /g, spaceReplacement );
  210. if ( simpleEncoding && !/['"`^{}<>|\\]|@(everyone|here)/.test(fragment) ) return '#' + fragment;
  211. return '#' + encodeURIComponent( fragment ).replace( /[!'()*~]/g, (match) => {
  212. return '%' + match.charCodeAt().toString(16).toUpperCase();
  213. } ).replace( /%3A/g, ':' ).replace( /%/g, '.' );
  214. }
  215. /**
  216. * Turn user input into a wiki.
  217. * @param {String} input - The user input referring to a wiki.
  218. * @returns {Wiki?}
  219. * @static
  220. */
  221. static fromInput(input = '') {
  222. try {
  223. if ( input instanceof URL ) return new Wiki(input);
  224. input = input.replace( /^(?:https?:)?\/\//, 'https://' );
  225. var regex = input.match( /^(?:https:\/\/)?([a-z\d-]{1,50}\.(?:gamepedia\.com|(?:fandom\.com|wikia\.org)(?:(?!\/(?:wiki|api)\/)\/[a-z-]{2,12})?))(?:\/|$)/ );
  226. if ( regex ) return new Wiki('https://' + regex[1] + '/');
  227. if ( input.startsWith( 'https://' ) ) {
  228. let project = wikiProjects.find( project => input.split('/')[2].endsWith( project.name ) );
  229. if ( project ) {
  230. regex = input.match( new RegExp( project.regex + `(?:${project.articlePath}|${project.scriptPath}|/?$)` ) );
  231. if ( regex ) return new Wiki('https://' + regex[1] + project.scriptPath);
  232. }
  233. let wiki = input.replace( /\/(?:index|api|load|rest)\.php(?:|[\?\/#].*)$/, '/' );
  234. if ( !wiki.endsWith( '/' ) ) wiki += '/';
  235. return new Wiki(wiki);
  236. }
  237. let project = wikiProjects.find( project => input.split('/')[0].endsWith( project.name ) );
  238. if ( project ) {
  239. regex = input.match( new RegExp( project.regex + `(?:${project.articlePath}|${project.scriptPath}|/?$)` ) );
  240. if ( regex ) return new Wiki('https://' + regex[1] + project.scriptPath);
  241. }
  242. if ( /^(?:[a-z-]{2,12}\.)?[a-z\d-]{1,50}$/.test(input) ) {
  243. if ( !input.includes( '.' ) ) return new Wiki('https://' + input + '.fandom.com/');
  244. else return new Wiki('https://' + input.split('.')[1] + '.fandom.com/' + input.split('.')[0] + '/');
  245. }
  246. return null;
  247. }
  248. catch {
  249. return null;
  250. }
  251. }
  252. /** @type {String[]} - Sites that support verification using OAuth2. */
  253. static oauthSites = [];
  254. [inspect.custom](depth, opts) {
  255. if ( typeof depth === 'number' && depth < 0 ) return this;
  256. const wiki = {
  257. href: this.href,
  258. origin: this.origin,
  259. protocol: this.protocol,
  260. username: this.username,
  261. password: this.password,
  262. host: this.host,
  263. hostname: this.hostname,
  264. port: this.port,
  265. pathname: this.pathname,
  266. search: this.search,
  267. searchParams: this.searchParams,
  268. hash: this.hash,
  269. articlepath: this.articlepath,
  270. articleURL: this.articleURL,
  271. spaceReplacement: this.spaceReplacement,
  272. mainpage: this.mainpage,
  273. mainpageisdomainroot: this.mainpageisdomainroot,
  274. }
  275. return 'Wiki ' + inspect(wiki, opts);
  276. }
  277. }
  278. /**
  279. * An article URL.
  280. * @class articleURL
  281. */
  282. class articleURL extends URL {
  283. /**
  284. * Creates a new article URL.
  285. * @param {String|URL|Wiki} [articlepath] - The article path.
  286. * @param {Wiki} [wiki] - The wiki.
  287. * @constructs articleURL
  288. */
  289. constructor(articlepath = '/index.php?title=$1', wiki) {
  290. super(articlepath, wiki);
  291. this.protocol = 'https';
  292. this.username = '';
  293. this.password = '';
  294. this.mainpage = '';
  295. this.spaceReplacement = ( wiki?.spaceReplacement || '_' );
  296. }
  297. [inspect.custom](depth, opts) {
  298. if ( typeof depth === 'number' && depth < 0 ) return this;
  299. if ( typeof depth === 'number' && depth < 2 ) {
  300. var link = this.href;
  301. var mainpage = link.replace( '$1', Wiki.toTitle(( this.mainpage || 'Main Page' ), this.spaceReplacement) );
  302. return 'articleURL { ' + inspect(link, opts) + ' => ' + inspect(mainpage, opts) + ' }';
  303. }
  304. return super[inspect.custom](depth, opts);
  305. }
  306. }
  307. export const toTitle = Wiki.toTitle;
  308. export const toSection = Wiki.toSection;
  309. export const fromInput = Wiki.fromInput;
  310. export const oauthSites = Wiki.oauthSites;