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
Current section
Files
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 :: Tesla.Client.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
```elixir
Wiki.Action.new(:dewiki)
|> Wiki.Action.get!(
action: :query,
meta: :siteinfo,
siprop: :statistics
)
```
### Response
```elixir
%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
```elixir
authed_api = Wiki.Action.new(:enwiki)
|> Wiki.Action.authenticate!(
Application.get_env(:example_app, :bot_username),
Application.get_env(:example_app, :bot_password)
)
```
### Response
```elixir
%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
```elixir
{: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
```elixir
%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
```elixir
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",
```elixir
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,
```elixir
Wiki.Action.new(:wikidatawiki)
|> Wiki.Action.get!(
action: :wbsearchentities,
search: "alphabet",
language: :en
)
```
Retrieve all data about a specific entity with ID "Q42",
```elixir
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`.
"""
alias Tesla.Multipart
alias Wiki.{Action.Session, Error, 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 ::
{:accumulate, true}
| {:adapter, module()}
| {:debug, true}
| {:timeout, integer()}
| {:user_agent, binary()}
@default_timeout 60_000
@default_adapter {Tesla.Adapter.Hackney, recv_timeout: @default_timeout}
@typedoc """
- `:accumulate` - Merge results from each step of a pipeline, rather than
overwriting with the latest response. Useful when carrying along
authentication or following continuations.
- `:adapter` - Override the HTTP adapter, defaults to Hackney.
- `:debug` - Turn on verbose logging by setting to `true`
- `: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:
```elixir
api = Wiki.Action.new(:dewiki)
```
Connect to a local development wiki:
```elixir
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.
"""
@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
```elixir
authed_api |> Wiki.Action.upload("/tmp/Triops_closeup.jpg")
```
### Response
```elixir
{: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:
```elixir
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
with {:ok, content} <- File.read(path) do
upload_file_content(session, Path.basename(path), content, params)
end
end
@doc """
Upload a file with content passed directly.
"""
@spec upload_file_content(Session.t(), filename :: binary(), content :: binary(), keyword) ::
Session.result()
def upload_file_content(session, filename, content, params \\ [])
when is_binary(content) do
with {:ok, new_session, token} <- get_token(session, :csrf) do
post(
new_session,
build_multipart(
filename,
content,
normalize_params(
[
action: :upload,
filename: filename,
token: token
] ++
params
)
)
)
end
end
defp build_multipart(filename, content, opts) do
Enum.reduce(
opts,
Multipart.new()
# TODO: stream chunked file using add_file and
# https://commons.wikimedia.org/wiki/Help:Chunked_upload
|> Multipart.add_file_content(content, filename),
fn {key, value}, mp -> mp |> Multipart.add_field(key, to_string(value)) 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, :get, query: normalize_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 | Tesla.Multipart) :: Session.result()
def post(session, params) when is_list(params),
do: request(session, :post, body: normalize_params(params))
def post(session, body) when is_struct(body, Tesla.Multipart),
do: request(session, :post, body: body)
@doc """
Assertive variant of `post`.
"""
@spec post!(Session.t(), keyword | Tesla.Multipart) :: 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(), :get | :post, keyword) :: Session.result()
defp request(session, method, opts) do
# TODO: This can be extracted into a generic StatefulAdapter now.
opts = [opts: session.state] ++ opts ++ [method: method]
with {:ok, result} <- Tesla.request(session.__client__, opts),
{:ok, result} <- validate(result) do
{:ok,
%Session{
__client__: session.__client__,
result: result.body,
state: Keyword.delete(result.opts, :opts)
}}
else
{:error, error = %Error{}} -> {:error, error}
{:error, error} -> {:error, %Error{message: "#{inspect(error)}"}}
end
end
@spec normalize_params(keyword) :: keyword
defp normalize_params(params) do
defaults = [
format: :json,
formatversion: 2
]
(defaults ++ params)
|> remove_boolean_false()
|> pipe_lists()
|> Enum.sort()
|> Enum.dedup()
end
defp remove_boolean_false(params) do
params
|> Enum.filter(fn {_, v} -> v not in [false, nil] 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(result) do
with nil <- validate_http_status(result.status),
nil <- validate_body_type(result.body),
nil <- validate_api_errors(result.body) do
{:ok, result}
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()) :: Tesla.Client.t()
defp client(url, opts) do
adapter = opts[:adapter] || @default_adapter
timeout = opts[:timeout] || @default_timeout
user_agent = opts[:user_agent] || Util.default_user_agent()
[
{Tesla.Middleware.BaseUrl, url},
{Tesla.Middleware.Timeout, timeout: timeout},
Wiki.StatefulClient.CookieJar,
Tesla.Middleware.FormUrlencoded,
{Tesla.Middleware.Headers,
[
{"user-agent", user_agent}
]},
Tesla.Middleware.FollowRedirects,
Wiki.StatefulClient.CumulativeResult,
Tesla.Middleware.Logger,
Tesla.Middleware.DecodeJson,
Tesla.Middleware.DecompressResponse
]
|> setup_maybe_accumulate(Keyword.get(opts, :accumulate))
|> setup_maybe_debug(Keyword.get(opts, :debug))
|> Tesla.client(adapter)
end
defp setup_maybe_accumulate(middleware, true), do: middleware
defp setup_maybe_accumulate(middleware, _),
do: middleware -- [Wiki.StatefulClient.CumulativeResult]
defp setup_maybe_debug(middleware, true), do: middleware
defp setup_maybe_debug(middleware, _),
do: middleware -- [Tesla.Middleware.Logger]
end
defmodule Wiki.StatefulClient.CookieJar do
@moduledoc false
@behaviour Tesla.Middleware
@impl true
def call(env, next, _opts) do
env = set_cookie_header(env, env.opts[:cookie_jar])
with {:ok, env} <- Tesla.run(env, next) do
{:ok, merge_cookies(env, env.opts[:cookie_jar])}
end
end
@spec set_cookie_header(Tesla.Env.t(), nil | map) :: Tesla.Env.t()
defp set_cookie_header(env, cookies_opt)
defp set_cookie_header(env, nil), do: env
defp set_cookie_header(env, cookie_jar) do
case HttpCookie.Jar.get_cookie_header_value(cookie_jar, URI.parse(env.url)) do
{:ok, serialized, updated_jar} ->
# FIXME: offers no way for the application to modify cookies
env
|> Tesla.put_opt(:cookie_jar, updated_jar)
|> Tesla.put_headers([{"cookie", serialized}])
{:error, :no_matching_cookies} ->
env
end
end
@spec merge_cookies(Tesla.Env.t(), nil | HttpCookie.Jar.t()) :: Tesla.Env.t()
defp merge_cookies(env, old_cookies)
defp merge_cookies(env, nil), do: merge_cookies(env, HttpCookie.Jar.new())
defp merge_cookies(env, old_cookies) do
updated_jar =
HttpCookie.Jar.put_cookies_from_headers(old_cookies, URI.parse(env.url), env.headers)
Tesla.put_opt(env, :cookie_jar, updated_jar)
end
end
defmodule Wiki.StatefulClient.CumulativeResult do
@moduledoc false
@behaviour Tesla.Middleware
@impl true
def call(env, next, _opts) do
with {:ok, env} <- Tesla.run(env, next) do
accumulated = recursive_merge(env.opts[:accumulated_result] || %{}, env.body)
{:ok,
Tesla.put_opt(env, :accumulated_result, accumulated)
|> Tesla.put_body(accumulated)}
end
end
@spec recursive_merge(map, map) :: map
defp recursive_merge(%{} = v1, %{} = v2), do: Map.merge(v1, v2, &recursive_merge/3)
# TODO: _key can be dropped
@spec recursive_merge(String.t(), map | String.t(), map | String.t()) :: map
defp recursive_merge(_key, v1, v2)
defp recursive_merge(_key, %{} = v1, %{} = v2), do: recursive_merge(v1, v2)
defp recursive_merge(_key, v1, v2) when is_list(v1) and is_list(v2), do: v1 ++ v2
defp recursive_merge(_key, v1, v2) when v1 == v2, do: v1
end