A pywikibot-based bot for the Polish Wikipedia, running as WikiZEITBot.
The bot scans pages in the category Kategoria:Strony monitorowane przez bota WikiZEIT, looks for a known template invocation on each page, dispatches by template name to the matching handler class, and writes the rendered result back to the page (using paginated subpages when the output is large).
The bot-managed content on the main page is wrapped in HTML comment markers placed after the template invocation:
{{TemplateName|…params…}}
<!-- WikiZEITBot:TemplateName|…params… -->
…rendered content…
<!-- /WikiZEITBot:TemplateName -->
- First run: the template stays put; the bot inserts the begin marker + content + end marker on the next line. Any text before the template and any text after the template are preserved.
- Subsequent runs: the bot finds the template + matching markers and replaces only the content between the markers. Text before the begin marker (including the template and any notes you add between them) and text after the end marker are untouched.
- Begin marker without a matching end marker (legacy / partial state): the bot replaces everything from the begin marker to the end of the page, restoring the proper end marker.
- Template missing but markers exist (a previous version of the bot removed the template): the bot reconstructs the template invocation from the marker's params and re-injects it back into the page, then proceeds with the normal flow. Self-healing — no manual restore needed.
Params are always read from the template when it's present (the template is the authoritative source). Change params by editing the template, not the marker. Subpages stay fully bot-managed (no markers); they're created by the bot and aren't expected to host user content.
-
{{Podopieczni|przewodnik=<mentor>|limit=<n>|edycje=<k>|email=tak}}— list mentees of the given mentor.przewodnikis the mentor username (required);limitoverrides the pagination size (mentees per page, defaults toMenteesHandler.items_per_page = 200);edycjecontrols thelimit=passed to the per-mentee{{Specjalna:Wkład/...}}transclusion (defaults to5);email=takopts the mentor into a personal e-mail summary of their own new mentees on each--summaryrun (see Email notifications). Blocked users and members of theeditor/sysopgroups are filtered out. WhenUSE_SQLis on, only mentees who edited withinLAST_EDIT_CUTOFF_DAYSsurvive, sorted by last-edit timestamp (descending); otherwise the remainder is sorted by editcount. The list is split into subpages ofMenteesHandler.items_per_pagementees each, named<page>/2,/3, … (padded to the width of the largest index). -
{{Fotografia}}(all params optional:fotograf,źródło,limit,nagłówek,próg dni,nazwa pliku,mime,etykieta,attrybuty) — gallery of the most recent Commons uploads. The user list comes fromfotograf(one or more usernames) orźródło(a wiki page to scrape).fotograftakes precedence when both are set.fotograf=<user>— single user. Withnagłówek=<text>adds=== <text> ===as an H3 header; without, just the bare gallery. No active/inactive split.fotograf=<user1>, <user2>, …— multi-user. Galleries render in the order typed, each with the standard plainlinks-styled H3 header.nagłówekandpróg dniare ignored.źródło=<page>(or omitted; defaultWikiprojekt:Fotografia/Uczestnicy; namespace-agnostic —Wikipedia:Projekty_szkolne_i_akademickie/<projekt>,Wikipedysta:<user>/galeria,Pomoc:<foo>, etc. all work) — the page wikitext is scraped for every[[User:...]],[[Wikipedysta:...]], or[[:user:...]]link (any context — tables, lists, prose;/Subpagepaths reduce to the bare username; duplicates deduped, first occurrence wins). Photographers split into== Aktywni ==and== Nieaktywni ==sections bypróg dni(default90days); each user gets a plainlinks H3 header. Sections are sorted alphabetically (case-fold).
If
źródłois set but the page doesn't exist, the body becomes<!-- nie znaleziono strony źródłowej: <name> -->.limitapplies in every mode and caps the per-user upload count (default10, max100).próg dnionly applies inźródłomode and only when the active/inactive split is on:próg dni=<n>(numeric, default90) — split into== Aktywni ==(last upload withinndays) and== Nieaktywni ==(older or no uploads).próg dni=nie— disable the split; render one flat alphabetical list of photographers with no== Aktywni ==/== Nieaktywni ==headers.- In
fotograf(single/multi) modes,próg dniis always ignored — galleries are shown regardless of how recently the user last uploaded.
Users with no Commons uploads still get their H3 +
<!-- brak zdjęć -->comment in page and multi modes; in single mode the comment replaces the gallery.Filtering and caption params (orthogonal to the mode):
nazwa pliku=<regex>— case-insensitiveREGEXPsubstring search againstimg_nameat SQL level. Example:nazwa pliku=\.jpe?g$matches.jpg/.jpegextensions;nazwa pliku=Wzlotmatches any file withWzlotanywhere in the name.mime=<type>— exact MIME filter.mime=image/jpegconstrains bothimg_major_mimeandimg_minor_mime;mime=imageconstrains only the major type.etykieta=<wiki code>— custom caption template for each gallery entry. Variables in the form{{token}}are replaced with values from the SQL row; unknown tokens (including literal templates like{{own}}) are left untouched. Default:<center>[[:commons:File:{{plik}}|{{plik}}]]</center>.attrybuty=<text>— extra attributes added to the opening<gallery>tag. Empty by default (tag stays as<gallery>, no trailing space). Example:attrybuty=mode{{=}}packed→<gallery mode=packed>.
Param-value escapes (MediaWiki convention, recognized in every param):
{{=}}→ literal=(needed because the first=separates key from value).{{!}}→ literal|(needed because|separates params at the top level).
Available
etykietavariables (Polish; English aliases also work — seetranslations.json):Token Source / format {{plik}}/{{filename}}img_name(spaces, no underscores){{rozmiar}}/{{size}}img_sizein bytes{{szerokość}}/{{width}}img_widthin pixels{{wysokość}}/{{height}}img_heightin pixels{{data}}/{{date}}upload date as YYYY-MM-DD{{czas}}/{{time}}upload time as ISO YYYY-MM-DDTHH:MM:SSZ{{ts}}raw 14-char MediaWiki timestamp {{sha1}}base36 hash {{mime}}major/minor(e.g.image/jpeg){{autor}}/{{user}}uploader username Add a new language by appending another top-level key to
translations.jsonand flippingLANGat the top offotografia.py.Files are fetched from the Wikimedia Commons SQL replica, so this template only works on Toolforge.
-
{{Wikipedysta:WikiZEITBot/szablon}}— no-op test slot. The bot recognizes it and does nothing. Reserved for new handlers under development.
bot.py— controller. Scans the category, matches the regex generated from registered handlers, parses params, callshandler.handle(...), and persists each returnedPageWrite.handlers.py—TemplateHandler,PaginatedHandler(chunking + main/sub envelopes),NoOpHandler, and thePageWritedataclass.podopieczni.py—MenteesHandlerand all mentee-specific code (mentee fetch, user-info via API or SQL, eligibility filter, render template).notifications.py—NotificationManager: tracks per-run counters, appends each run to~/state/notifications/runs.jsonl, and when invoked with--summaryreads the accumulated log, mails a digest to the operator, sends per-mentor newcomer summaries to opted-in mentors (email=tak) via MediaWiki's emailuser API, and clears the file. Error emails (send_failure) are always sent regardless of--summary. The operator digest and error mail rely on the local Exim relay (Toolforge only); the per-mentor mail goes through the wiki API. Unit-tested bytest_notifications.py—python -m unittest test_notifications.py.db.py— SQLite store at~/state/bot.db(auto-created on first run). Tables:mentor_params(last seen template params per mentor),mentee_membership(per-mentor mentee roster withfirst_seen/last_seen),digest_meta(last sent digest timestamp).update_mentor(...)atomically reconciles the roster and returns the(added, removed)sets.state.py— legacy generic JSON store; currently unused, kept for reference.parsing.py—find_template_in_text(brace-counting template locator) andparse_params(top-level|/ first=split that respects nested{{...}}and[[...]]). Pure stdlib; unit-tested bytest_parsing.py—python -m unittest test_parsing.py.translations.json— per-language map frometykietatokens (plik,data, …) to the canonical SQL column / format key. Add a top-level key to add a language; switch by settingLANGinfotografia.py.
Adding a new template:
- Subclass
TemplateHandler(one-shot output) orPaginatedHandler(paginated output). - Set
template_nameto the exact wiki template name. - Implement
handle(), or for paginated handlersfetch_items()+render_item(). - Register an instance in
HANDLERSinsidebot.py.
git clone git@github.com:WikiZEIT/bot.git
cd bot
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txtTo upgrade later: pip install -r requirements.txt --upgrade.
Create user-config.py next to bot.py:
family = 'wikipedia'
mylang = 'pl'
usernames['wikipedia']['pl'] = 'WikiZEITBot'
password_file = 'user-password.py'Create user-password.py from a BotPassword
issued at Specjalna:HasłaBotów. The first BotPassword argument is the suffix only, not the
full user@suffix login:
('WikiZEITBot', BotPassword('<suffix>', '<generated-token>'))The BotPassword must grant Edit existing pages, Create, edit, and move pages, and High-volume
editing (without the last grant pywikibot logs a 'bot' right wasn't activated warning and edits
don't get the bot flag). For the per-mentor email feature (email=tak, see
Email notifications) it must also grant Send email to other users
(sendemail) — a BotPassword's effective rights are the intersection of the account's rights and
the ticked grants, so without it the emailuser API returns "You don't have permission to send
mail". In addition, the WikiZEITBot account itself must have a confirmed email address
(Specjalna:Preferencje); the API refuses to send from an account without one.
Configurable constants:
bot.py (controller):
| Constant | Meaning |
|---|---|
DEBUG |
When True, the bot writes each page's output to DEBUG_DIR/<n> files instead of saving to the wiki. Flip to False for production. |
DEBUG_DIR |
Output directory for the dry-run (default pages). |
EMAIL_NOTIFICATIONS |
When True (and DEBUG is False), send a per-run summary email after a successful run and an urgent email with traceback on crash. Forced off while DEBUG=True. |
CATEGORY |
Name of the monitoring category the bot scans. |
podopieczni.py (mentees handler):
| Constant | Meaning |
|---|---|
USE_SQL |
When True, fetch user info from the Wikimedia SQL replica (replica.my.cnf) and sort mentees by last-edit timestamp. Only works on Toolforge. Leave False for local testing. |
LAST_EDIT_CUTOFF_DAYS |
In SQL mode, drop mentees whose last edit is older than this many days. |
EXCLUDED_GROUPS |
User groups filtered out of the mentee list (default {'editor', 'sysop'}). |
MenteesHandler.items_per_page |
Pagination size. |
MenteesHandler.subpage_prefix |
Wikitext prepended to each subpage body. |
Then:
python bot.py # full update: re-render every monitored page, no email
python bot.py --new-only # incremental: skip pages whose params + mentee list
# match the state stored in ~/state/bot.db
python bot.py --summary # full update + email the accumulated digest, then clear the log
python bot.py --migrate # populate ~/state/bot.db from the current wiki state
# without re-rendering anything (one-off bootstrap)After deploying or upgrading the bot for the first time on a host where pages were rendered by a
previous version, run python bot.py --migrate once. It walks the monitored category, fetches
each mentor's current eligible mentee set, and inserts the rows into mentee_membership with a
sentinel first_seen = 1970-01-01 so they don't appear as newcomers in the next digest. The
following --new-only run will then be a cache hit and skip every page that genuinely hasn't
changed.
--new-only and --summary compose freely — typical Toolforge layout uses --new-only hourly
(append-only logging, no mail) and --summary daily (full run, send digest).
The MenteesHandler reconciles its mentee roster against ~/state/bot.db after every successful
run. A subsequent --new-only run is a cache hit when the stored params match the current ones
and the stored mentee set matches the current eligible set and the corresponding wiki
pages exist; otherwise it re-renders and updates the DB. Each commit logs Zmiany u <mentor>: +N nowych, -M odeszło so you can see joins and departures in the job log.
The daily digest reads mentee_membership.first_seen >= digest_meta.last_digest_time and
appends a "Nowi podopieczni" section listing newcomers per mentor since the previous digest.
On a --summary run the operator always receives the full digest at TO_ADDR
(bot@wikizeit.edu.pl). In addition, any mentor whose {{Podopieczni}} template carries
email=tak receives their own message listing only their newcomers since the previous digest
(same as the operator summary) — never the operator digest, and never other mentors' data. The
full roster is deliberately left out (a mentor may have thousands of mentees); the mail links to
the mentor's page instead. Mentors without new mentees in a given digest are not contacted.
These per-mentor messages are sent through MediaWiki's emailuser API
(pywikibot.User(site, mentor).send_email(...)), so the bot never handles the mentor's address —
Wikipedia relays the mail to whatever address the mentor confirmed. This requires that:
- the bot account has a confirmed e-mail address, and
- the mentor confirmed their e-mail and left "Zezwalaj innym użytkownikom na wysyłanie do mnie e-maili" enabled.
The per-mentor mails are sent before the operator digest is composed, and every outcome is
reported back in the digest under a Powiadomienia e-mail do mentorów section, e.g.:
Powiadomienia e-mail do mentorów: 2 wysłane, 1 pominięte, 1 błędne
wysłane: Alice, Bob
pominięte: Carol (nie przyjmuje e-maili)
błędy: Dave (RuntimeError: ...)
so you can confirm from the summary alone that each mentor mail went out. Nothing here crashes the
bot: a mentor who is not emailable (isEmailable() is False) is reported as pominięte, and any
send error — including the bot account itself lacking a confirmed e-mail — is caught per mentor and
reported as błędne. A failure to reach one mentor never blocks the others or the digest
bookkeeping (log clearing / last_digest_time update).
The MenteesHandler writes ~/state/podopieczni/<mentor>.json after every successful run
containing the current template params and a SHA-256 hash of the sorted eligible-mentee names.
A subsequent --new-only run reads that file; if both params and mentees_hash match the
current run, the handler returns [] and the page is skipped entirely (no wiki traffic).
Tool: wikizeit-bot (tool home at /data/project/wikizeit-bot/).
become wikizeit-bot
git clone git@github.com:WikiZEIT/bot.git
cd bot && python3 -m venv venv && venv/bin/pip install -r requirements.txt pymysql
toolforge-jobs run wikizeit-hourly \
--image python3.11 \
--schedule '0 * * * *' \
--command '/data/project/wikizeit-bot/bot/venv/bin/python /data/project/wikizeit-bot/bot/bot.py --new-only'
toolforge-jobs run wikizeit-daily \
--image python3.11 \
--schedule '0 0 * * *' \
--command '/data/project/wikizeit-bot/bot/venv/bin/python /data/project/wikizeit-bot/bot/bot.py --summary'Keep user-config.py and user-password.py in /data/project/wikizeit-bot/ (outside the repo)
and pass --env PYWIKIBOT_DIR=/data/project/wikizeit-bot to the jobs command so pywikibot finds
them.
.github/workflows/deploy.yaml runs on every push and pull request to master:
- test — installs
requirements.txton Python 3.11 (matching the Toolforge image) and runspython -m unittest test_parsing.py test_notifications.py.PYWIKIBOT_NO_USER_CONFIG=1is set becausenotifications.pyimportspywikibotat load time and CI has nouser-config.py. - deploy —
needs: test, so it only runs when the tests pass, and only onmaster. It SSHes into the Toolforge bastion and, as the tool account, pulls the latest code (git pull --ff-only) and reinstalls dependencies ($HOME/pvenv/bin/pip install -r requirements.txt). A failing test blocks the deploy entirely.
Configure these repository secrets (Settings → Secrets and variables → Actions):
| Secret | Value |
|---|---|
SSH_SERVER |
login.toolforge.org |
SSH_USER |
your Wikitech shell username (a maintainer of the wikizeit-bot tool) |
SSH_PRIVATE_KEY |
a private key whose public half is on that Wikitech account |
SSH_PORT |
22 |
SSH_PATH |
/data/project/wikizeit-bot/bot (the repo checkout) |
Both commands run as the tool account via become (in a single bash -lc so $HOME resolves to
the tool's home), keeping the checkout owned by tools.wikizeit-bot. --ff-only makes the deploy
fail loudly rather than create a merge commit if the checkout has diverged. Dependencies are
reinstalled from requirements.txt on every deploy, so a change there is picked up automatically.
Copyright (C) 2026 Jakub T. Jankiewicz
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/ or LICENSE in this repository.