Packages

API client library for any MediaWiki-based site, such as Wikipedia or Wikimedia Commons. Provides the Action API, realtime feed ingestion, and ORES scoring.

Current section

Files

Jump to
mediawiki_client lib action.ex
Raw

lib/action.ex

defmodule Wiki.Action.Session do
@moduledoc """
This module provides a struct for holding private connection state and
accumulated results.
## Fields
- `result` - Map with recursively merged values from all requests made using this session.
- `state` - Cache for session state and accumulation.
"""
@type client :: Req.Request.t()
@type result :: {:ok, t()} | {:error, any}
@type state :: keyword
@type t :: %__MODULE__{
__client__: client,
result: map,
state: keyword
}
defstruct __client__: nil,
result: %{},
state: []
end
defmodule Wiki.Action do
@moduledoc """
Adapter to the MediaWiki [Action API](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Main_page)
To create an API client call `new/2` and specify a site to connect to.
Most functions return an api session which holds the latest results and can be
reused to pipe chain requests together. As an example, when getting site
statistics:
<!-- tabs-open -->
### Request
```
Wiki.Action.new(:dewiki)
|> Wiki.Action.get!(
action: :query,
meta: :siteinfo,
siprop: :statistics
)
```
### Response
```
%Wiki.Action.Session{
...
result: %{
"batchcomplete" => true,
"query" => %{
"statistics" => %{
"activeusers" => 19393,
"admins" => 188,
"articles" => 2583636,
"edits" => 211249646,
"images" => 130213,
"jobs" => 0,
"pages" => 7164514,
"queued-massmessages" => 0,
"users" => 3716049
}
}
},
...
}
```
<!-- tabs-close -->
## Authentication
Log in with a [bot password](https://www.mediawiki.org/wiki/Manual:Bot_passwords):
<!-- tabs-open -->
### Request
```
authed_api = Wiki.Action.new(:enwiki)
|> Wiki.Action.authenticate!(
Application.get_env(:example_app, :bot_username),
Application.get_env(:example_app, :bot_password)
)
```
### Response
```
%Wiki.Action.Session{
...
result: %{
"login" => %{
"lguserid" => 2,
"lgusername" => "Admin",
"result" => "Success"
},
...
}
```
<!-- tabs-close -->
Now the client can be used to get a token and make an edit:
<!-- tabs-open -->
### Request
```
{:ok, api, token} = Wiki.Action.get_token(authed_api, :csrf)
Wiki.Action.post!(api,
action: :edit,
title: "Sandbox",
assert: :user,
token: token,
appendtext: "~~~~ was here."
)
```
### Response
```
%Wiki.Action.Session{
...
result: %{
"edit" => %{
"contentmodel" => "wikitext",
"new" => true,
"newrevid" => 38,
"newtimestamp" => "2025-11-06T13:36:43Z",
"oldrevid" => 0,
"pageid" => 20,
"result" => "Success",
"title" => "Sandbox",
"watched" => true
}
},
...
}
```
<!-- tabs-close -->
## Continuation
Any action that returns more items than the allowed batch size will provide
one or more -`continue` fields which can be transparently used to stream
results from multiple requests.
<!-- tabs-open -->
### Request
```
Wiki.Action.new(:dewiki)
|> Wiki.Action.stream(
action: :query,
list: :recentchanges,
rclimit: 5
)
|> Stream.flat_map(fn response -> response["query"]["recentchanges"] end)
|> Stream.map(fn rc -> rc["timestamp"] <> " " <> rc["title"] end)
|> Stream.each(&IO.puts/1)
|> Stream.run()
```
### Response
```
2025-11-06T13:46:12Z Peter Schnupp
2025-11-06T13:46:03Z Kategorie:Mitglied der Hall of Fame des deutschen Sports
2025-11-06T13:46:02Z Portal:Radsport/Qualitätssicherung
2025-11-06T13:46:02Z Kategorie:Wikipedia:DHBW/2025-Controlling
2025-11-06T13:46:02Z Öffentlicher Personennahverkehr in Wien
2025-11-06T13:45:59Z Brouchaud
2025-11-06T13:45:43Z Steingutfabrik Paetsch
2025-11-06T13:45:38Z Kriegerdenkmal Feldmoching (1923)
2025-11-06T13:45:38Z Hotel Bellevue (Ruhla)
2025-11-06T13:45:35Z Emil und die Detektive
2025-11-06T13:45:33Z Parasesarma
...
```
<!-- tabs-close -->
## Wikibase
The [Wikidata](https://www.wikidata.org/) project provides structured data for
other wiki projects, and can be accessed through the Action API.
Examples:
Search for entities called "alphabet",
```
Wiki.Action.new(:wikidatawiki)
|> Wiki.Action.get!(
action: :wbsearchentities,
search: "alphabet",
language: :en
)
```
Search for entities with "Frank Zappa" anywhere in the description or contents,
```
Wiki.Action.new(:wikidatawiki)
|> Wiki.Action.get!(
action: :wbsearchentities,
search: "alphabet",
language: :en
)
```
Retrieve all data about a specific entity with ID "Q42",
```
Wiki.Action.new(:wikidatawiki)
|> Wiki.Action.get!(
action: :wbgetentities,
ids: "Q42"
)
```
## Defaults
A few parameters are automatically added for convenience, but can be
overridden if desired:
* The `:format` parameter defaults to `:json`.
* `:formatversion` defaults to `2`.
"""
require Logger
alias Wiki.{Action.Session, Error, ReqDebug, SiteMatrix, Util}
@typedoc """
Can be a `SiteMatrix.Spec` as returned by `Wiki.SiteMatrix.get`, dbname as an
atom like `:enwiki`, or raw `api.php` endpoint for the wiki you will connect
to. For example, "https://en.wikipedia.org/w/api.php"
"""
@type site_action_handle :: SiteMatrix.Spec.t() | atom() | String.t()
@type client_option ::
{:debug, true}
| {:plug,
(Plug.Conn.t() -> Plug.Conn.t())
| (Plug.Conn.t(), Plug.opts() -> Plug.Conn.t())
| {module(), any()}}
| {:timeout, integer()}
| {:user_agent, binary()}
@default_timeout 60_000
@typedoc """
- `:debug` - Turn on verbose logging by setting to `true`
- `:plug` - In testing, replace network requests with a Plug-like mock
- `:timeout` - API call timeout, in seconds. Defaults to #{@default_timeout / 1000}s
- `:user_agent` - Override the generic, built in user-agent header string.
"""
@type client_options :: [client_option()]
@doc """
Create a new client session
## Arguments
- `site` - target wiki and its action endpoint
- `opts` - configuration options which modify client behavior
## Examples
Connect to German Wikipedia:
```
api = Wiki.Action.new(:dewiki)
```
Connect to a local development wiki:
```
api = Wiki.Action.new("http://dev.wiki.local.wmftest.net:8080/w/api.php")
```
"""
@spec new(site_action_handle(), client_options()) :: Session.t()
def new(site, opts \\ [])
def new(dbname, opts) when is_atom(dbname),
do: new(SiteMatrix.get!(dbname), opts)
def new(spec, opts) when is_struct(spec, SiteMatrix.Spec) do
new(SiteMatrix.action_api(spec), spec.opts ++ opts)
end
def new(action_endpoint, opts) when is_binary(action_endpoint) do
%Session{
__client__: client(action_endpoint, opts)
}
end
@doc """
Make requests to authenticate a client session. This should only be done using
a [bot username and password](https://www.mediawiki.org/wiki/Manual:Bot_passwords),
which can be created for any normal user account.
## Arguments
- `session` - Base session pointing to a wiki.
- `username` - Bot username, may be different than the final logged-in username.
- `password` - Bot password. Protect this string, it allows others to take on-wiki actions on your behalf.
## Return value
Authenticated session object.
"""
@spec authenticate(Session.t(), String.t(), String.t()) :: Session.result()
def authenticate(session, username, password) do
with {:ok, new_session, token} <- get_token(session, :login) do
post(new_session,
action: :login,
lgname: username,
lgpassword: password,
lgtoken: token
)
end
end
@doc """
Assertive variant of `authenticate`
"""
@spec authenticate!(Session.t(), String.t(), String.t()) :: Session.t()
def authenticate!(session, username, password) do
case authenticate(session, username, password) do
{:ok, session} -> session
{:error, error} -> raise error
end
end
@doc """
Helper to make a token request
The Action API requires various
[tokens](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Tokens) for
login, edit actions, and so on. This function simplifies the request.
# TODO: support multiple atoms
"""
@spec get_token(Session.t(), atom()) :: {:ok, Session.t(), token :: binary()} | {:error, any}
def get_token(session, type) do
with {:ok, response} <-
get(session,
action: :query,
meta: :tokens,
type: type
),
token when is_binary(token) <- response.result["query"]["tokens"]["#{type}token"] do
{:ok, response, token}
else
nil ->
{:error, %Error{message: "No token found in successful response"}}
x ->
x
end
end
@doc """
Upload a file from a local path
<!-- tabs-open -->
### Request
```
authed_api |> Wiki.Action.upload("/tmp/Triops_closeup.jpg")
```
### Response
```
{:ok, %Wiki.Action.Session{
...
result: %{
"upload" => %{
"filename" => "Triops_closeup.jpg",
"imageinfo" => %{
"bitdepth" => 8,
"canonicaltitle" => "File:Triops closeup.jpg",
"comment" => "",
"commonmetadata" => [
%{
"name" => "Copyright",
"value" => [%{"name" => 0, "value" => "Karsten Grabow"}]
},
%{
"name" => "JPEGFileComment",
"value" => [%{"name" => 0, "value" => "Copyright Karsten Grabow"}]
}
],
"descriptionurl" => "http://dev.wiki.local.wmftest.net:8080/wiki/File:Triops_closeup.jpg",
"extmetadata" => %{
"DateTime" => %{
"hidden" => "",
"source" => "mediawiki-metadata",
"value" => "2025-11-07T21:31:17Z"
},
"ObjectName" => %{
"source" => "mediawiki-metadata",
"value" => "Triops closeup"
}
},
"height" => 827,
"html" => "..."
"mediatype" => "BITMAP",
"metadata" => [
%{
"name" => "Copyright",
"value" => [%{"name" => 0, "value" => "Karsten Grabow"}]
},
%{
"name" => "JPEGFileComment",
"value" => [%{"name" => 0, "value" => "Copyright Karsten Grabow"}]
},
%{"name" => "MEDIAWIKI_EXIF_VERSION", "value" => 2}
],
"mime" => "image/jpeg",
"parsedcomment" => "",
"sha1" => "12ce3375983eb929fba91bf5267ad932929e9217",
"size" => 222944,
"timestamp" => "2025-11-07T21:31:17Z",
"url" => "http://dev.wiki.local.wmftest.net:8080/w/images/4/45/Triops_closeup.jpg",
"user" => "Admin",
"userid" => 2,
"width" => 1280
},
"result" => "Success"
}
},
}}
```
<!-- tabs-close -->
Upload with additional parameters:
```
authed_api |> Wiki.Action.upload(
"/tmp/Test.jpg",
text: "This is a test file",
comment: "Upload initial draft from Elixir",
ignorewarnings: true
)
```
"""
@spec upload(Session.t(), binary(), keyword) :: Session.result()
def upload(session, path, params \\ []) when is_list(params),
do: upload_file_content(session, Path.basename(path), File.stream!(path), params)
@doc """
Upload a file with content passed directly, as binary or a stream
"""
@spec upload_file_content(
Session.t(),
filename :: binary(),
content :: binary() | Enumerable.t(),
keyword
) ::
Session.result()
def upload_file_content(session, filename, content, params \\ []) do
with {:ok, new_session, token} <- get_token(session, :csrf) do
request(new_session,
method: :post,
form_multipart:
normalize_params(
[
action: :upload,
filename: filename,
token: token
] ++
params
) ++ [file: {content, filename: filename}]
)
end
end
@doc """
Make an API GET request
## Arguments
- `session` - `Wiki.Action.Session` object.
- `params` - Keyword list of query parameters as atoms or strings.
## Return value
Session object with its `.result` populated.
"""
@spec get(Session.t(), keyword) :: Session.result()
def get(session, params),
do: request(session, method: :get, params: params)
@doc """
Assertive variant of `get`.
"""
@spec get!(Session.t(), keyword) :: Session.t()
def get!(session, params) do
case get(session, params) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Make an API POST request.
## Arguments
- `session` - `Wiki.Action.Session` object. If credentials are required for this
action, you should have created this object with the `authenticate/3` function.
- `params` - Keyword list of query parameters as atoms or strings.
## Return value
Session object with a populated `:result` attribute.
"""
@spec post(Session.t(), keyword) :: Session.result()
def post(session, params) when is_list(params),
do: request(session, method: :post, form: normalize_params(params))
@doc """
Assertive variant of `post`.
"""
@spec post!(Session.t(), keyword) :: Session.t()
def post!(session, body) do
case post(session, body) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Make a GET request and follow continuations until exhausted or the stream is closed.
## Arguments
- `session` - `Wiki.Action.Session` object.
- `params` - Keyword list of query parameters as atoms or strings.
## Return value
Enumerable `Stream`, where each returned chunk is a raw result map, possibly
containing multiple records. This corresponds to `session.result` from the other
entry points.
"""
@spec stream(Session.t(), keyword) :: Enumerable.t()
def stream(session, params) do
Stream.resource(
fn -> {session, :start} end,
fn
{prev, :start} ->
do_stream_get(prev, params)
{prev, :cont} ->
get_continuation(prev.result)
|> case do
nil -> {:halt, nil}
continue -> do_stream_get(prev, params ++ continue)
end
end,
fn _ -> nil end
)
end
defp do_stream_get(session, params) do
next = get!(session, params)
{[next.result], {next, :cont}}
end
defp get_continuation(result) do
case result do
# TODO: Test that a cross between a list and query can be continued
# in both dimensions.
%{"continue" => continue} ->
Map.to_list(continue)
%{"query-continue" => continue} ->
continue
|> Map.values()
|> Enum.flat_map(&Map.to_list/1)
_ ->
nil
end
end
@spec request(Session.t(), keyword) :: Session.result()
defp request(session, opts) do
with {:ok, response} <- Req.request(session.__client__, opts),
{:ok, response} <- validate(response) do
{:ok,
%Session{
__client__: session.__client__ |> update_cookies(response),
result: response.body
}}
else
{:error, error = %Error{}} -> {:error, error}
{:error, error} -> {:error, %Error{message: "#{inspect(error)}"}}
end
end
defp update_cookies(request, response),
do: Req.Request.merge_options(request, cookie_jar: response.private[:cookie_jar])
@spec normalize_params(nil | keyword) :: keyword
defp normalize_params(params)
defp normalize_params(nil), do: []
defp normalize_params(params) do
defaults = [
format: :json,
formatversion: 2
]
(defaults ++ params)
|> remove_boolean_false()
|> pipe_lists()
|> stringify_atoms()
|> Enum.sort()
|> Enum.dedup()
end
defp remove_boolean_false(params) do
params
|> Enum.filter(fn {_, v} -> v not in [false, nil] end)
end
defp stringify_atoms(params) do
Enum.map(params, fn
{k, v} when is_atom(v) -> {k, Atom.to_string(v)}
{k, v} when not is_binary(v) -> {k, inspect(v)}
{k, v} -> {k, v}
end)
end
defp pipe_lists(params) do
params
|> Enum.map(fn
{k, v} when is_list(v) -> {k, pipe_list(v)}
entry -> entry
end)
end
defp pipe_list(values) do
if Enum.any?(values, fn v -> String.contains?(to_string(v), "|") end) do
# Use a special join character because pipe would conflict with the value.
unit_separator = "\x1f"
Enum.join([""] ++ values, unit_separator)
else
Enum.join(values, "|")
end
end
defp validate(response) do
with nil <- validate_http_status(response.status),
nil <- validate_body_type(response.body),
nil <- validate_api_errors(response.body) do
{:ok, response}
end
end
defp validate_http_status(status) do
case status do
status when status >= 200 and status < 300 -> nil
status -> {:error, %Error{message: "Error received with HTTP status #{status}"}}
end
end
defp validate_body_type(body) do
with body when is_map(body) <- body,
body when body != %{} <- body do
nil
else
_ -> {:error, %Error{message: "Empty response"}}
end
end
defp validate_api_errors(body) do
with nil <- body["error"],
nil <- body["errors"] do
nil
else
error when is_map(error) -> {:error, %Error{message: summarize_legacy_error(error)}}
errors when is_list(errors) -> {:error, %Error{message: summarize_new_error(errors)}}
end
end
defp summarize_legacy_error(error) do
error["info"] ||
error["code"] ||
"Unknown error (legacy format)"
end
defp summarize_new_error(errors) do
# TODO: multiple errors
case(List.first(errors)) do
%{"text" => text} -> text
%{"html" => html} -> html
%{"key" => key, "params" => params} -> [key, params] |> List.flatten() |> Enum.join("-")
%{"code" => code} -> code
_ -> "unknown"
end
end
@spec client(binary(), keyword()) :: Req.Request.t()
defp client(url, opts) do
((opts |> Keyword.drop([:debug, :timeout])) ++
[
base_url: url,
receive_timeout: opts[:timeout] || @default_timeout,
user_agent: opts[:user_agent] || Util.default_user_agent()
])
|> Req.new()
|> ReqProxy.attach()
|> HttpCookie.ReqPlugin.attach(cookie_jar: HttpCookie.Jar.new())
|> Req.Request.prepend_request_steps(
normalize: fn request ->
update_in(
request.options[:params],
&normalize_params/1
)
end
)
|> ReqDebug.attach(debug: opts[:debug])
end
end