В рунете я почти не встречал материалов о том, как писать расширения для MediaWIki (платформы, на которой работает Википедия). Основной стартовой точкой при написании расширений был и остается официальный сайт платформы, но там процесс расписан не очень дружелюбно по отношению к новичкам. Попробуем же это исправить.

В этой статье я покажу, как написать простейшее расширение для Медиавики, включающее в себя новый метод API, расширение парсера и немного js/css для фронтенда. А чтобы не было скучно, приплетем сюда работу с Google Knowledge Graph.

Расширения MediaWiki

MediaWiki — модульная платформа, куда можно устанавливать расширения для добавления самого разного функционала. Помимо того, что расширения могут реализовывать какой-то свой независимый функционал (например, добавлять какие-нибудь виджеты), оно также может и модифицировать функциональность платформы: например, менять принцип работы поиска или модифицировать внешний вид платформы. Посмотреть примеры расширений можно на официальном сайте платформы.

Пишутся расширения, как правило, на php+jQuery. Возможность встраиваться в код ядра MediaWiki (или в код других расширений) реализована через т.н. хуки. Хуки позволяют вызывать дополнительный код по заданным событиям. Примерами таких событий могут быть: сохранение страницы, вызов поиска по сайту, открытие страницы на редактирование и так далее.

Расширения MediaWiki позволяют делать что угодно: работать напрямую с базой, модифицировать вики-движок, работать с файловой системой и так далее. С одной стороны, это позволяет добавлять какой угодно функционал, но с другой — накладывает на вас большую ответственность при установке новых сторонних расширений. Впрочем, довольно лирики, приступим к написанию своего расширения.

Что будем писать?

Готовое расширение можно взять тут:
https://github.com/Griboedow/GoogleKnowledgeGraph

Давайте развлечемся и напишем что-нибудь бесполезное. Скажем, расширение, которое будет вытаскивать описания с Google Knowledge Graph.

Т.е. расширение будет вот это:

Код этого приложения прост и изящен как 
<GoogleKnowledgeGraph query="Мэльхэнанвенанхытбельхын"/>

Превращать в это:

Штука довольно бесполезная, но она послужит хорошей иллюстрацией. Еще и с графом знаний Гугла поиграемся!

Расширение сделано исключительно в учебных целях, не рекомендую его использовать на настоящих вики. Гугл предоставляет 100 000 бесплатных запросов в день. Для небольших вики это не проблема, но на серьезных сайтах ресурс будет исчерпан очень быстро.

Как оно будет работать

Примерный принцип работы расширения выглядит так:

  1. Пользователь сохраняет страницу, где в тексте присутствуют теги <GoogleKnowledgeGraph query="Ричард Докинз">.

    • MediaWIki позволяет использовать не только формат тега, но и формат функции парсера <link>: {{#GoogleKnowledgeGraph||query=Ричард Докинз}}.

  2. Расширение функции парсера превращает тег в html код <span class="googleKnowledgeGraph">Ричард Докинз</span>

  3. JS код при загрузке страницы идет по всем элементам .googleKnowledgeGraph и запрашивает через API нашего же расширения описания терминов, подставляя их в title.

  4. API нашего расширения будет максимально примитивным: он будет передавать запросы от фронтенда на Google API, чистить ответ от всего лишнего и передавать очищенное описание сущности на фронт.

В целом, можно было бы обойтись и без фронтенда, но запросы на внешние сайты могут проходить не очень быстро. Лучше показать основной контент страницы пораньше, и в фоне догрузить необязательный контент. К тому же мы тут учимся, а не пишем серьезный код, верно?

Итого, нам потребуется:

  1. Определить манифест нашего расширения.

  2. Расширить MediaWIki API, добавив запрос на получение описания из Google Knowledge Graph

  3. Расширить парсер MediaWiki, добавив обработку нового тега.

  4. Добавить JS код, который будет выполняться по загрузке страницы

  5. Подгрузить наше расширение в MediaWiki

  6. Поделиться результатом наших трудов с сообществом.

А еще перед началом работы вам потребуется токен для работы с Google Knowledge Graph API. Сгенерировать его можно тут.

Создаем структуру расширения

Типичная иерархия файлов и папок для MediaWIki расширения выглядит так:

extensions                    <-- Папка всех расширений MediaWiki
L-- GoogleKnowledgeGraph      <-- Подпапка с нашим расширением 
    +-- extension.json   <-- Манифест нашего расширения
    +-- i18n           <-- Каталог с используемыми строками для разных языков
    ¦   +-- en.json    <-- Строки на английском
    ¦   +-- qqq.json   <-- Описания строк для облегчения жизни переводчиков
    ¦   L-- ru.json    <-- Строки на русском
    +-- includes                             <-- PHP код
    ¦   +-- ApiGoogleKnowledgeGraph.php      <-- Расширение API
    ¦   L-- GoogleKnowledgeGraph.hooks.php   <-- Расширение парсера и другие хуки
    L-- modules                                <-- Папка с JS модулями 
        L-- ext.GoogleKnowledgeGraph           <-- В нашем случае модуль только 1
            +-- ext.GoogleKnowledgeGraph.css   <-- CSS стили нашего модуля
            L-- ext.GoogleKnowledgeGraph.js    <-- JS код нашего модуля

Разберем содержимое всех файлов по порядку, и начнем с самого простого.

Интернационализация (i18n)

Для того, чтобы нашим расширением было удобно пользоваться на всех языках, можно воспользоваться стандартной системой интернационализации banana-i18n. Помимо облегчения интернационализации, эта система также позволяет хранить все тексты в одном месте (а не раскиданными по коду). Выглядит это примерно так:

qqq.json

{
	"@metadata": {
		"authors": [ "Developer Name" ]
	},
	"googleknowledgegraph-description": "Description of the extension, to be show in Special:Vesion.",
	"apihelp-askgoogleknowledgegraph-summary" : "Help string for 'askgoogleknowledgegraph' API request",
	"apihelp-askgoogleknowledgegraph-param-query": "Help string for 'query' parameter of API request 'askgoogleknowledgegraph'"
}

en.json

{
	"@metadata": {
		"authors": [ "Nikolai Kochkin" ]
	},
	"googleknowledgegraph-description": "The extension gets brief description from Google Knowledge Graph",
	"apihelp-askgoogleknowledgegraph-summary" : "API to get description from Google Knowledge Graph",
	"apihelp-askgoogleknowledgegraph-param-query": "String to ask from Google Knowledge Graph"
}

Создаем манифест расширения (extension.json)

Для начала разберемся, как нам сообщить MediaWiki, что нужно загрузить то или иное расширение. Путей на самом деле два:

  • Использоватьrequire_once( '/path/to/file.php' ). Этот метод считается устаревшим, так что мы его подробно не будем рассматривать.

  • Использовать функцию wfLoadExtension('ExtensionName'). Сейчас этот способ считается основным, так что на нем и остановимся.

Второй способ подразумевает наличие в папке файла extension.json с описанием манифеста приложения (как оно называется, из чего состоит, какие хуки использует и так далее).

Определяем манифест (файл extension.json):

{
	"name": "GoogleKnowledgeGraph",
	"version": "0.1.0",
	"author": [
		"Nikolai Kochkin"
	],
	"url": "https://habr.com/ru/company/veeam/blog/544534/",
	"descriptionmsg": "googleknowledgegraph-description",
	"license-name": "GPL-2.0-or-later",
	"type": "parserhook",
	"requires": {
		"MediaWiki": ">= 1.29.0"
	},
	"MessagesDirs": {
		"GoogleKnowledgeGraph": [
			"i18n"
		]
	},
	"AutoloadClasses": {
		"GoogleKnowledgeGraphHooks": "includes/GoogleKnowledgeGraph.hooks.php",
		"ApiAskGoogleKnowledgeGraph": "includes/ApiAskGoogleKnowledgeGraph.php"
	},
	"APIModules": {
		"askgoogleknowledgegraph": "ApiAskGoogleKnowledgeGraph"
	},
	"Hooks": {
		"OutputPageParserOutput": "GoogleKnowledgeGraphHooks::onBeforeHtmlAddedToOutput",
		"ParserFirstCallInit": "GoogleKnowledgeGraphHooks::onParserSetup"
	},
	"ResourceFileModulePaths": {
		"localBasePath": "modules",
		"remoteExtPath": "GoogleKnowledgeGraph/modules"
	},
	"ResourceModules": {
		"ext.GoogleKnowledgeGraph": {	
			"localBasePath": "modules/ext.GoogleKnowledgeGraph",
			"remoteExtPath": "GoogleKnowledgeGraph/modules/ext.GoogleKnowledgeGraph",
			"scripts": [
				"ext.GoogleKnowledgeGraph.js"
			],
			"styles": [
				"ext.GoogleKnowledgeGraph.css"
			]
		}
	},
	"config": {
		"GoogleApiLanguage": {
			"value": "ru",
			"path": false,
			"description": "In which language you want to get result from the Knowledge Graph",
			"public": true
		},
		"GoogleApiToken": {
			"value": "",
			"path": false,
			"description": "API token to be used with Google API",
			"public": false
		}
	},
	"ConfigRegistry": {
		"GoogleKnowledgeGraph": "GlobalVarConfig::newInstance"
	},
	"manifest_version": 2
}
Разбираем extension.json по частям

Первая часть файла определяет то, что пользователь увидит в описании расширения на странице Special:Version

	"name": "GoogleKnowledgeGraph",
	"version": "0.1.0",
	"author": [
		"Nikolai Kochkin"
	],
	"url": "https://habr.com/ru/company/veeam/blog/544534/",
	"descriptionmsg": "googleknowledgegraph-description",
	"license-name": "GPL-2.0-or-later",
	"type": "parserhook",

Далее мы указываем зависимости нашего расширения: с какими версиями MediaWIki расширение может работать, какие версии php требуются, какие расширения должны быть уже установлены и так далее.

"requires": {
		"MediaWiki": ">= 1.29.0"
	},

Затем мы указываем, где искать файлы со строками i18n

"MessagesDirs": {
		"GoogleKnowledgeGraph": [
			"i18n"
		]
	},

И сообщаем, в каких файлах искать классы для автоподгрузки. Подробнее тут.

"AutoloadClasses": {
		"GoogleKnowledgeGraphHooks": "includes/GoogleKnowledgeGraph.hooks.php",
		"ApiAskGoogleKnowledgeGraph": "includes/ApiAskGoogleKnowledgeGraph.php"
	},

Заявляем, что мы реализовываем API метод askgoogleknowledgegraph в классе ApiAskGoogleKnowledgeGraph

	"APIModules": {
		"askgoogleknowledgegraph": "ApiAskGoogleKnowledgeGraph"
	},

Перечисляем, какие коллбеки для каких хуков у нас реализованы

	"Hooks": {
		"BeforePageDisplay": "GoogleKnowledgeGraphHooks::onBeforePageDisplay",
		"ParserFirstCallInit": "GoogleKnowledgeGraphHooks::onParserSetup"
	},

Сообщаем, что модули наши лежат в папке modules

	"ResourceFileModulePaths": {
		"localBasePath": "modules",
		"remoteExtPath": "GoogleKnowledgeGraph/modules"
	},

И определяем наш фронтенд модуль с js и css. Когда модулей несколько, можно указать в коде зависимости между ними.

	"ResourceModules": {
		"ext.GoogleKnowledgeGraph": {	
			"localBasePath": "modules/ext.GoogleKnowledgeGraph",
			"remoteExtPath": "GoogleKnowledgeGraph/modules/ext.GoogleKnowledgeGraph",
			"scripts": [
				"ext.GoogleKnowledgeGraph.js"
			],
			"styles": [
				"ext.GoogleKnowledgeGraph.css"
			]
		}
	},

И, наконец, задаем дополнительные параметры конфигурации нашего расширения

	"config": {
		"GoogleApiLanguage": {
			"value": "ru",
			"path": false,
			"description": "In which language you want to get result from the Knowledge Graph",
			"public": true
		},
		"GoogleApiToken": {
			"value": "",
			"path": false,
			"description": "API token to be used with Google API",
			"public": false
		}
	},
	"ConfigRegistry": {
		"GoogleKnowledgeGraph": "GlobalVarConfig::newInstance"
	},

В LocalSettings.php опции будут иметь стандартный префикс wg

$wgGoogleApiToken = 'your-google-token';
$wgGoogleApiLanguage = 'ru';

И, наконец, задаем версию схемы манифеста

"manifest_version": 2

Мы используем лишь небольшой список поддерживаемых полей манифеста. Почитать обо всех полях можно тут.

Расширяем API

Для начала реализуем API.

В extension.json мы заявили, что у нас будет метод askgoogleknowledgegraph, реализованный в классе ApiAskGoogleKnowledgeGraph из файла includes/ApiAskGoogleKnowledgeGraph.php:

// extension.json fragment

"AutoloadClasses": {
    <...>
    "ApiAskGoogleKnowledgeGraph": "includes/ApiAskGoogleKnowledgeGraph.php"
},
"APIModules": {         
  "askgoogleknowledgegraph": "ApiAskGoogleKnowledgeGraph"     
},

Теперь реализуем наш метод. Файл includes/ApiAskGoogleKnowledgeGraph.php:

<?php

/** 
 * Класс включает в себя реализацию и описание API метода askgoogleknowledgegraph
 * Для простоты я не реализую кеширование, любопытные могут подсмотреть реализацию тут: 
 * https://github.com/wikimedia/mediawiki-extensions-TextExtracts/blob/master/includes/ApiQueryExtracts.php
 */
use MediaWiki\MediaWikiServices;

class ApiAskGoogleKnowledgeGraph extends ApiBase {

	public function execute() {

		$params = $this->extractRequestParams();
		// query - обязательный параметр, так что $params['query'] всегда определен
		$description = ApiAskGoogleKnowledgeGraph::getGknDescription( $params['query'] );


		/**
		 * Определяем результат для Get запроса. 
		 * На самом деле Post запрос отработает с тем же успехом, 
		 * если специально не отслеживать тип запроса ?\_(?)_/?.
		 */
		$this->getResult()->addValue( null, "description", $description );
	}


	/** 
	 * Список поддерживаемых параметров метода
	 */
	public function getAllowedParams() {
		return [
			'query' => [
				ApiBase::PARAM_TYPE => 'string',
				ApiBase::PARAM_REQUIRED => true,
			]
		];
	}


	/**
	 * Получаем данные из Google Knowledge Graph, 
     * предполагая, что самый первый результат и есть верный.
	 */
	private static function getGknDescription( $query ) {
		
		/**
		 * Вытаскиваем параметры языка и токен.
		 * Все параметры в LocalSettings.php имеют префикс wg, например: wgGoogleApiToken.
		 * Здесь же мы их указываем без префикса
		 */
		$config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'GoogleKnowledgeGraph' );
		$gkgToken = $config->get( 'GoogleApiToken' );
		$gkgLang = $config->get( 'GoogleApiLanguage' );


		$service_url = 'https://kgsearch.googleapis.com/v1/entities:search';
		$params = [
			'query' => $query ,
			'limit' => 1,
			'languages' => $gkgLang,
			'indent' => TRUE,
			'key' => $gkgToken,
		];

		$url = $service_url . '?' . http_build_query( $params );

		$ch = curl_init();
		curl_setopt( $ch, CURLOPT_URL, $url) ;
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
		$response = json_decode( curl_exec( $ch ), true );
		curl_close( $ch );

		if( count( $response['itemListElement'] ) == 0 ){
			return "Nothing found by your request \"$query\"";
		}
		
		if( !isset( $response['itemListElement'][0]['result'] ) ){
			return "Unknown GKG result format for request \"$query\"";
		}

		if( !isset($response['itemListElement'][0]['result']['detailedDescription'] ) ){
			return "detailedDescription was not provided by GKG for request \"$query\"";
		}
		
		if( !isset( $response['itemListElement'][0]['result']['detailedDescription']['articleBody'] ) ){
			return "articleBody was not provided by GKG for request \"$query\"";
		}
		
		return $response['itemListElement'][0]['result']['detailedDescription']['articleBody'];
	}

}

Теперь мы можем обращаться по апи к нашей вики:

Get /api.php?action=askgoogleknowledgegraph&query=Выхухоль&format=json

Response body:
{
	"description": "Вы?хухоль, или русская выхухоль, или хоху?ля, — вид млекопитающих отряда насекомоядных из трибы Desmanini подсемейства Talpinae семейства кротовых. Один из двух видов трибы; вторым видом является пиренейская выхухоль."
}

Расширяем парсер и используем прочие хуки

// Фрагмент файла extension.json

"AutoloadClasses": {
		"GoogleKnowledgeGraphHooks": "includes/GoogleKnowledgeGraph.hooks.php",
		<...>
},	
"Hooks": {
		"BeforePageDisplay": "GoogleKnowledgeGraphHooks::onBeforePageDisplay",
		"ParserFirstCallInit": "GoogleKnowledgeGraphHooks::onParserSetup"
},

В extension.json мы заявили, что в классе GoogleKnowledgeGraphHooks из файла includes/GoogleKnowledgeGraph.hooks.php реализуем расширения для хуков:

  • OutputPageParserOutput в методе onBeforeHtmlAddedToOutput;

  • ParserFirstCallInit в методе onParserSetup

Немножко про используемые хуки:

  • OutputPageParserOutput позволяет выполнить какой-то код после того, как парсер закончил формировать html, но перед тем, как html был добавлен к аутпуту. Здесь мы, например, можем подгрузить фронтенд. Фронтенд мы целиком расположили в модуле ext.GoogleKnowledgeGraph, так что достаточно будет подгрузить его.

  • ParserFirstCallInit позволяет расширить парсер дополнительными методами. Мы добавим в парсер обработку тега <GoogleKnowledgeGraph>.

Итак, реализация (файл includes/GoogleKnowledgeGraph.hooks.php):

<?php

/**
 * Хуки расширения GoogleKnowledgeGraph 
 */
class GoogleKnowledgeGraphHooks {

	/**
	 * Сработает хук после окончания работы парсера, но перед выводом html. 
	 * Детали тут: https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageParserOutput
	 */
	public static function onBeforeHtmlAddedToOutput( OutputPage &$out, ParserOutput $parserOutput ) {
		// Добавляем подгрузку модуля фронтенда для всех страниц, его определение ищи в extension.json
		$out->addModules( 'ext.GoogleKnowledgeGraph' );
		return true;
	}

	
	/**
	 * Расширяем парсер, добавляя обработку тега <GoogleKnowledgeGraphHooks>
	 */
	public static function onParserSetup( Parser $parser ) {
		$parser->setHook( 'GoogleKnowledgeGraph', 'GoogleKnowledgeGraphHooks::processGoogleKnowledgeGraphTag' );
		return true;
	}


	/**
	 * Реализация обработки тега <GoogleKnowledgeGraph> 
	 */
	public static function processGoogleKnowledgeGraphTag( $input, array $args, Parser $parser, PPFrame $frame ) {
		// Парсим аргументы, переданные в формате <GoogleKnowledgeGraph arg1="val1" arg2="val2" ...> 
		if( isset( $args['query'] ) ){
			$query = $args['query'];
		}
		else{
			// В тег не был передан аргумент query, так что и выводить нам нечего
			return '';
		}

		return '<span class="googleKnowledgeGraph">' . htmlspecialchars( $query ) . '</span>';
	}

}

Добавляем фронтенд

Фронтенд свяжет воедино все, что мы реализовали выше.

	// Фрагмент файла extension.json
  
  "ResourceModules": {
		"ext.GoogleKnowledgeGraph": {
			"localBasePath": "modules",
			"remoteExtPath": "GoogleKnowledgeGraph/modules",
			"scripts": [
				"ext.GoogleKnowledgeGraph.js"
			],
			"styles": [
				"ext.GoogleKnowledgeGraph.css"
			],
			"dependencies": [
			]
		}
	},
  "ResourceFileModulePaths": {
		"localBasePath": "modules",
		"remoteExtPath": "GoogleKnowledgeGraph/modules"
	},

В extension.json мы заявили, что у нас есть один модуль ext.GoogleKnowledgeGraph, который находится в папке modules и состоит из двух файлов:

  • modules/ext.GoogleKnowledgeGraph/ext.GoogleKnowledgeGraph.js

  • modules/ext.GoogleKnowledgeGraph/ext.GoogleKnowledgeGraph.css

Загрузку модуля мы реализовали чуть раньше в методе onBeforeHtmlAddedToOutput. Определим теперь и сам код модуля.

Для начала зададим стили
(файл modules/ext.GoogleKnowledgeGraph/ext.GoogleKnowledgeGraph.css):

.googleKnowledgeGraph{
    border-bottom: 1px dotted #000;
    text-decoration: none;
}

А теперь возьмемся за JS
(файл modules/ext.GoogleKnowledgeGraph/ext.GoogleKnowledgeGraph.js):

( function ( mw, $ ) {
  /**
   * Ищем все элементы с <span class="googleKnowledgeGraph">MyText</span>,
   * вытаскиваем MyText и отправляем запрос
   * /api.php?action=askgoogleknowledgegraph&query=MyText
   * После чего добавляем результат в 'title'.
   */
	$( ".googleKnowledgeGraph" ).each( function( index, element ) {
		$.ajax({
			type: "GET", 
			url: mw.util.wikiScript( 'api' ),
			data: { 
				action: 'askgoogleknowledgegraph', 
				query: $( element ).text(),
				format: 'json',
			},
			dataType: 'json',
			success: function( jsondata ){
				$( element ).prop( 'title', jsondata.description );
			}
		});
	});
}( mediaWiki, jQuery ) );

JS код довольно прост. jQuery нам достался даром, поскольку MediaWiki подгружает его автоматически.

Подгружаем наше расширение и радуемся

Для загрузки расширения, как мы уже обсуждали, потребуется поправить файл LocalSettings.php. Добавляем в самый конец:

// Фрагмент файла LocalSettings.php

<?php
<...>
  
wfLoadExtension( 'GoogleKnowledgeGraph' );
$wgGoogleApiToken = "your-google-token";
$wgGoogleApiLanguage = 'ru';

Можно пробовать! Добавим на страницу что-нибудь эдакое:

Даже <GoogleKnowledgeGraph query="прикольный флот"/> может стать отстойным.

И получим:

Делимся с сообществом

Если есть возможность поделиться расширением с общественностью, то можно создать страницу на MediaWiki с кратким описанием, что ваше расширение может сделать (не забудьте скриншоты: лучше один раз увидеть, чем сто раз прочитать). На страницы с описаниями расширений обычно добавляют шаблон Extension, поля которого хорошо задокументированы. Если же возникнут сложности, всегда можно скопировать его с другой страницы расширений и подправить отличающиеся поля.

Типичная страница с описанием расширения
Типичная страница с описанием расширения

Заключение

В статье был описан случай довольно простого расширения, но, на самом деле, такие расширения как iFrame, CategoryTree, Drawio и многие другие не очень далеко ушли по сложности.

За скобками остались такие вещи, как работа с базой, кэширование, OOUI и много-многое другое. Все ж я вас не напугать хотел, а как раз наоборот — показать, что писать расширения под вики на самом деле совсем не сложно и не страшно.

Ссылки