A generic, config-driven web scraper that monitors websites for changes and sends email notifications. Define what to scrape using CSS selectors in a JSON config file, and format notifications with Liquid templates.
Designed to run as a cron job. Each rule has its own schedule (cron expression), so the script can be invoked frequently (e.g. every 5 minutes) and each rule runs only when its schedule is due.
Configuration works best with AI agents like OpenClaw, ClaudeCode, OpenCode, or Codex.
pip install mutimon
This installs the mon command.
git clone https://github.com/jcubic/mutimon.git
cd mutimon
pip install .
This installs the mon command from the local source, including all dependencies.
On the first run, the tool creates ~/.mutimon/ with a skeleton config and example rules (Hacker News + Bitcoin price alerts):
mon
# Config not found at /home/user/.mutimon/config.json
# Creating skeleton configuration in /home/user/.mutimon...
# Done. Edit /home/user/.mutimon/config.json to configure your scraping rules.
Edit ~/.mutimon/config.json with your SMTP credentials and scraping rules, then run again.
mon # process rules; only prints notifications and errors
mon --force # ignore schedules, run all rules now
mon --force <rule> ... # ignore schedule, run only the named rule(s)
mon --init # seed state for all rules without sending notifications
mon --init <rule> ... # seed state for specific rule(s) without sending notifications
mon --reset <rule> ... # delete all stored data (state, last-run, saved email) for the named rule(s)
mon --dry-run # fetch and display data, bypass schedules, no state changes
mon --save-email # save email to file instead of sending via SMTP
mon --validate # validate config against schema and exit
mon --list # list all rule names (usable with --force <rule>)
mon --ai-guide # print the AI instruction guide for adding websites
mon --cron # print a cron entry with resolved path (default: every 5 min)
mon --cron "0 8 * * *" # print a cron entry with a custom schedule
mon --completion bash # output shell completion script (bash, zsh, or fish)
mon -v, --verbose # show detailed progress (page fetches, counts, skipped rules)
mon -q, --quiet # suppress all output including errors
Use --cron to generate a cron entry with the correct resolved path (works with pyenv, virtualenvs, etc.):
mon --cron # default: every 5 minutes
mon --cron "0 * * * *" # custom: every hour
Install it directly:
(crontab -l 2>/dev/null; mon --cron) | crontab -
Each rule's schedule field controls when it actually executes, so running mon frequently (e.g. every 5 minutes) is safe — rules only fire when their cron expression matches.
--init)Use --init to populate state files without sending any notifications. This is useful when adding new rules — without --init, the first run would send emails for all existing items on the page:
mon --init # seed state for all rules
mon --init my-rule # seed state for a specific rule
Like --force, --init bypasses schedules. All items are fetched, validated, and saved to state, but no emails are sent.
--reset)Use --reset to delete all stored data for one or more rules — the state file, the last-run timestamp, and any saved email. The next run then treats every item as new:
mon --reset my-rule # clear one rule
mon --reset rule-a rule-b # clear several rules
At least one rule name is required (there is deliberately no "reset everything" form). Unknown rule names are rejected with an error, and nothing is fetched or sent.
Tab completion is available for bash, zsh, and fish. It completes long options and rule names for --force, --init, and --reset.
Generate the completion script:
mon --completion bash # or zsh, fish
Install for your shell:
# bash — add to ~/.bashrc
eval "$(mon --completion bash)"
# zsh — add to ~/.zshrc
eval "$(mon --completion zsh)"
# fish — add to ~/.config/fish/config.fish
mon --completion fish | source
~/.mutimon/
config.json # main configuration
templates/ # Liquid email templates
hackernews
data/ # state files (tracked items per rule)
hackernews
.lastrun_hackernews # last run timestamp for schedule tracking
emails/ # saved copies of sent emails
logs/ # per-rule debug logs (when log: true)
rule_name.log
A JSON Schema is provided for editor autocompletion and validation. Add "$schema": "./config.schema.json" to your config file, or point to the raw URL if hosted on GitHub.
The config is validated against the schema on every run. If the config is invalid, an error email with all validation errors is sent to all rule recipients and the script exits.
The config file (~/.mutimon/config.json) has three sections:
email -- SMTP server"email": {
"server": {
"host": "smtp.example.com",
"port": 587,
"password": "your-password",
"email": "you@example.com"
}
}
defs -- Reusable scraping definitions and commandsEach definition describes how to fetch and parse data from a website. The optional commands key defines reusable Liquid tag commands (see Commands).
"hackernews": {
"url": "https://news.ycombinator.com",
"pagination": { ... },
"query": {
"type": "list",
"selector": "tr.athing.submission",
"id": { ... },
"filter": { ... },
"variables": { ... }
}
}
Fields:
| Field | Required | Description |
|---|---|---|
url | yes | URL to fetch. Supports Liquid variables from rule params, e.g. https://example.com?q={{query}} |
format | no | "html" (default) or "xml". Use "xml" for RSS/Atom feeds or any XML document. Switches BeautifulSoup to the lxml XML parser (requires lxml). |
userAgent | no | Custom User-Agent header. If omitted, a default browser-like User-Agent is used. Useful for RSS feeds or APIs that require a specific User-Agent. |
params | no | List of parameter names used in the URL template |
pagination | no | Pagination config (see below) |
query | no | When omitted, the definition acts as a health check — returns HTTP response metadata instead of parsing content. |
query.type | yes* | "list" (multiple items) or "single" (one item). *Required when query is present. |
query.selector | yes* | CSS selector for item container(s). For XML, use XML element names (e.g. item for RSS, entry for Atom). *Required when query is present. |
validator | no | Default validator applied to all rules using this definition. AND-merged with input-level validators (see Definition-level validator and track). |
track | no | Default track applied to all rules using this definition. Overridden by input-level track (see Definition-level validator and track). |
query.id | no | How to extract a unique ID per item (see below) |
query.filter | no | Filter to exclude items (see below) |
query.expect | no | List of CSS selectors that must exist on the page (see Expected structure). Sends error email if missing. |
query.reject | no | List of CSS selectors that indicate no real results (see Reject selectors). Returns 0 items if any match. |
query.variables | yes | Named fields to extract (see below) |
rules -- What to runEach rule references a definition and can override params, email recipient, template, etc.
{
"ref": "hackernews",
"name": "hackernews",
"schedule": "0 */6 * * *",
"subject": "Hacker News: {{count}} new stories",
"template": "./templates/hackernews",
"email": "you@example.com"
}
Fields:
| Field | Required | Description |
|---|---|---|
ref | conditional | Name of the definition in defs. Optional when every input entry specifies its own ref (aggregation pattern, see Aggregating multiple sources). |
name | yes | Unique rule name. Used for state file (~/.mutimon/data/<name>) |
schedule | no | Cron expression or array of expressions (see Schedule). If omitted, runs every time. |
subject | yes | Liquid template for the email subject line |
template | yes | Path to the Liquid template file (relative to ~/.mutimon/) |
email | yes | Recipient email address |
params | no | Values for the definition's URL template variables. Used when input is not specified. |
input | no | One or more input entries with params and optional validators (see Multiple inputs). Overrides params. |
flatten | no | When true (default), items from all inputs are merged into a flat list. When false, items is a nested list grouped by input entry (see Grouped items). |
dedupe | no | Array of variable names to use as a composite dedup key. Items with identical values for all listed fields are collapsed (first occurrence kept). See Field-based deduplication. |
enabled | no | Set to false to disable a rule without removing it from the config. Disabled rules are skipped on every run. Default true. |
log | no | When true, write per-run debug logs to ~/.mutimon/logs/<rule_name>.log with timestamps, item counts, notification decisions, and returning-ID detection. |
Each variable in query.variables defines how to extract a value from a matched element:
"title": {
"selector": ".titleline > a",
"value": {
"type": "text"
}
}
| Type | Description | Extra fields |
|---|---|---|
text | Inner text of the element | |
attribute | HTML attribute value | name -- attribute name (e.g. "href") |
html | Raw inner HTML of the element | Use the html2text Liquid filter in templates to convert to plain text |
| Field | Description |
|---|---|
regex | Extract a capture group from the raw value. Uses group(1) if available. |
prefix | String prepended to the final value. Useful for turning relative URLs into absolute. |
parse | Convert the extracted string to a typed value. "number": plain numeric parsing for integers and floats, strips commas as thousands separators (e.g. "1,234" -> 1234, "3.14" -> 3.14). "money": locale-aware currency parsing via babel, auto-detects page language from <html lang> or Content-Language header, strips currency symbols and percent signs, handles US ($70,528.40), European (11,8000 zł), and mixed (11.800,50 €) formats. "list": split the value into a list using the delimiter regex (default \s*,\s*), use {% for x in item.field %} in templates. "json": parse the value as JSON, then optionally extract structured data with query (see JSON extraction). "url": URL normalization using urljoin — when combined with prefix, the value is resolved as a relative URL against the prefix base (e.g. prefix: "https://example.com" + value "/page?id=1" → "https://example.com/page?id=1"). Unlike plain prefix which concatenates strings, "url" handles relative paths, query strings, and fragments correctly. "date": normalize a (possibly localized) date string to ISO 8601 ("YYYY-MM-DD") using the page's detected locale for month names, so date commands like {% today %} / {% fresh %} work on non-English dates (e.g. the Polish "10 lipca" → "2026-07-10", "10 lip 2026" → "2026-07-10"); a missing year defaults to the current year, and unparseable values pass through unchanged. Parsed values are used by validators. |
delimiter | Regex pattern used to split the value when parse is "list". Defaults to \s*,\s* (comma with optional surrounding whitespace). |
query | Only for parse: "json". Defines how to navigate and extract variables from the parsed JSON using JMESPath (see JSON extraction). |
| Field | Description |
|---|---|
default | Fallback value if the selector doesn't match or the value is empty |
sibling | When true, search the next sibling element instead of within the matched element. Needed when data is split across adjacent HTML elements (e.g. Hacker News stores title and score in separate <tr> rows). |
collect | When true, collect ALL matching elements (using select() instead of select_one()). Returns a list that can be iterated in templates with {% for skill in item.skills %}. Useful for extracting lists of tags, skills, or categories from repeated elements. |
find | Array of chainable DOM traversal steps for advanced element navigation. See DOM traversal. |
transform | Array of DOM mutation steps applied before value extraction. See DOM transformation. |
| Selector | Description |
|---|---|
:self | References the container element itself instead of searching for a child. Useful when the container is an <a> tag and you need its href attribute. |
"url": {
"selector": "a.job__title-link",
"value": {
"type": "attribute",
"name": "href",
"regex": "^(/.*)",
"prefix": "https://useme.com"
}
}
This selects the href attribute from a.job__title-link, extracts the path with a regex, then prepends the domain.
When an item contains repeated elements (e.g. skill tags, categories), use collect: true to extract all matches as a list:
"skills": {
"selector": ".skill-tag",
"value": { "type": "text" },
"collect": true
}
This finds all .skill-tag elements inside the item container and returns a list like ["TypeScript", "React", "Node.js"]. Use a loop in the template:
{% for skill in item.skills %}{{ skill }}{% unless forloop.last %}, {% endunless %}{% endfor %}
When the container element itself holds the data you need (e.g. an <a> tag with an href), use :self:
"url": {
"selector": ":self",
"value": {
"type": "attribute",
"name": "href",
"prefix": "https://example.com"
}
}
find)For complex pages where the data you need isn't inside the item container, use find to navigate the DOM with chainable traversal steps. This works like jQuery methods — each step takes the result of the previous one as input.
| Method | Description |
|---|---|
["select", selector] | Run select_one() within the current element |
["until", selector] | Collect next siblings until one matches the selector (inclusive) |
["siblings"] | Collect all next siblings into a fragment |
Steps are applied in order. The result of each step becomes the input for the next.
Wikipedia discussion pages use DiscussionTools markup where thread content is spread across sibling elements rather than nested inside a container. The find chain navigates from the heading to the reply button, collecting everything in between:
"content": {
"selector": "div.mw-heading2",
"find": [
["until", ".ext-discussiontools-init-replylink-buttons"]
],
"transform": [
["remove", ".ext-discussiontools-init-replylink-buttons"],
["remove_after", "[data-mw-comment-sig]"]
],
"value": { "type": "html" }
}
This selects the heading container, collects all siblings until the reply button, then transforms the result by removing UI elements and stripping signatures before extracting the raw HTML.
transform)Use transform to modify a DOM fragment before extracting its value. Each step mutates a copy of the element (the original is never modified). Combine with find to first locate, then clean up content.
| Method | Description |
|---|---|
["remove", selector] | Remove all elements matching the CSS selector |
["remove_after", selector] | Remove the first match and all following siblings in its parent |
Wikipedia comments end with signature markup. Use remove_after to cut everything from the signature marker onward:
"transform": [
["remove", ".reply-button"],
["remove_after", "[data-mw-comment-sig]"]
]
html2text filterUse type: "html" to extract the raw inner HTML of an element instead of plain text. This preserves the full markup including links, code blocks, and formatting.
To convert HTML to readable plain text in email templates, use the built-in html2text Liquid filter:
{{ item.content | html2text | truncate: 1000 }}
The html2text filter converts HTML to Markdown-like plain text, preserving code blocks and link URLs. The truncate filter can be chained to limit output length.
Some websites embed structured data as JSON inside <script> tags (e.g. Next.js apps use <script id="__NEXT_DATA__">). When the HTML elements don't contain all the data you need, you can extract it from the embedded JSON instead.
Use parse: "json" combined with a query to navigate the JSON structure using JMESPath expressions.
"locations": {
"selector": "script#__NEXT_DATA__",
"value": {
"type": "text",
"parse": "json",
"query": {
"type": "list",
"path": "props.pageProps.data.items[?id == `{{id}}`].offers[]",
"variables": {
"city": { "path": "displayWorkplace" },
"url": { "path": "offerAbsoluteUri" }
}
}
}
}
How it works:
selector selects the element containing JSON (e.g. a <script> tag) — standard CSS selectortype: "text" extracts the text content — same as any other variableparse: "json" parses the text as a JSON objectquery navigates the parsed JSON and extracts variables:| Field | Required | Description |
|---|---|---|
type | yes | "list" (returns array of objects) or "single" (returns one object) |
path | no | JMESPath expression to navigate the JSON. Supports Liquid variables ({{id}}, {{name}}, etc.) rendered against the current item's data. If omitted, the root JSON object is used. |
variables | yes | Named fields to extract from each result. Each has a path (JMESPath sub-expression). |
The path supports Liquid variable interpolation, so you can match JSON entries to the current HTML item. For example, {{id}} is replaced with the item's extracted ID before the JMESPath query runs.
JMESPath is a query language for JSON. Common patterns:
| Expression | Description |
|---|---|
foo.bar.baz | Navigate nested objects |
items[0] | Array index |
items[*].name | Get name from all array entries |
items[?id == \123`]` | Filter: entries where id equals 123 |
items[?score > \50`]` | Filter: entries where score > 50 |
items[].offers[] | Flatten nested arrays |
Note: literal values in JMESPath filters use backticks (`), not quotes. See the JMESPath tutorial for full syntax.
When query.type is "list", the variable is a list of objects accessible in templates:
{% for loc in item.locations %}
* {{ loc.city }}: {{ loc.url }}
{% endfor %}
When query.type is "single", the variable is a flat object:
{{ item.metadata.author }} - {{ item.metadata.date }}
JSON can also appear in HTML attributes. Use type: "attribute" with parse: "json":
"config": {
"selector": "[data-config]",
"value": {
"type": "attribute",
"name": "data-config",
"parse": "json",
"query": {
"type": "single",
"variables": {
"status": { "path": "status" },
"count": { "path": "meta.count" }
}
}
}
}
Pracuj.pl (a Next.js app) lists job offers with multi-location variants. The HTML card only shows the title, but the city-specific URLs are in __NEXT_DATA__:
"url_list": {
"selector": "script#__NEXT_DATA__",
"value": {
"type": "text",
"parse": "json",
"query": {
"type": "list",
"path": "props.pageProps.dehydratedState.queries[0].state.data.groupedOffers[?offers[0].partitionId == `{{id}}`].offers[]",
"variables": {
"city": { "path": "displayWorkplace" },
"url": { "path": "offerAbsoluteUri" }
}
}
}
}
The {{id}} in the path is the item's ID extracted from the HTML (data-test-offerid attribute). JMESPath filters the groupedOffers array to find the matching entry, then flattens its offers[] sub-array. Each offer's displayWorkplace and offerAbsoluteUri are extracted as city and url.
The id field in the query spec controls how the scraper identifies items it has already seen.
"id": {
"source": "url",
"regex": ",(\\d+)/$"
}
Takes the url variable value and extracts the ID using a regex. The source can reference either a variable name (from variables) or a param name (from input/params). When using input, params are merged into items before ID extraction, so "source": "symbol" works if symbol is a param.
"id": {
"type": "attribute",
"name": "id"
}
Reads the id attribute directly from the matched element (e.g. <tr id="47415919">).
If no id spec is provided, the url variable is used as the identity. If there's no url either, a hash of all variables is used.
Sometimes the same listing appears multiple times with different IDs (e.g. a job offer posted under multiple categories gets a different URL slug for each). Use dedupe on the rule to collapse these based on extracted field values:
{
"ref": "job-site",
"name": "jobs",
"dedupe": ["title", "company"],
"subject": "{{count}} new offer(s)",
"template": "./templates/jobs",
"email": "you@example.com"
}
Items with identical values for all listed fields are collapsed — only the first occurrence is kept. This runs after ID-based deduplication and before state comparison.
The filter field excludes items based on CSS class:
"filter": {
"selector": ".job__header-details--date",
"exclude_class": "job__header-details--closed"
}
This finds .job__header-details--date within each item and skips the item if it has the class job__header-details--closed. Items where the filter selector doesn't match any element are also excluded.
The expect field on a query spec lists CSS selectors that must exist on the page. If any are missing, the scraper sends an error email about HTML structure changes instead of silently producing empty results.
"query": {
"expect": [".text-center img[alt='Linux']", ".pagination"],
"selector": "...",
...
}
This is checked on the first page only. Useful for detecting when a website redesigns and your selectors break.
The reject field is the inverse of expect — it lists CSS selectors that indicate the page has no real results. If any selector matches, the page returns 0 items. This is useful for sites that show recommended or unrelated content when there are no actual matches for the search query.
"query": {
"reject": ["nfj-no-offers-found-header"],
"selector": "...",
...
}
For example, nofluffjobs.com shows a "Brak wyników wyszukiwania" message and recommended jobs when a language has no remote offers. The reject selector detects the no-results element and prevents those recommendations from being treated as real results.
The input field allows a single rule to scrape multiple pages with different parameters and combine the results into one email. This is useful for monitoring multiple items on the same website (e.g. multiple stock symbols).
input can be a single object or an array:
{
"ref": "bankier",
"name": "akcje",
"subject": "[bankier.pl] Zmiany Akcji",
"template": "./templates/bankier",
"email": "you@example.com",
"input": [
{ "params": { "symbol": "BIOMAXIMA" }, "validator": { "test": "{{price}} > 10" } },
{ "params": { "symbol": "AGORA" }, "validator": { "test": "{{price}} > 9.5" } },
{ "params": { "symbol": "ASSECOPOL" } },
{ "params": { "symbol": "POLTREG" } }
]
}
Each entry fetches the URL with its own params. If input is omitted, the rule's params field is used directly (backward compatible).
Params from each input entry are merged into the extracted items, so they're available in templates (e.g. {{symbol}}).
eachWhen multiple inputs share the same structure and only differ by one parameter, use each to avoid repeating the same object:
"input": {
"each": { "var": "subreddit", "values": ["Python", "JavaScript", "scheme"] },
"params": { "feed_url": "https://www.reddit.com/r/{{subreddit}}/new.rss" },
"validator": { "@id": "hiring-posts" }
}
This expands into three input entries, one per value, each with its own params where {{subreddit}} is replaced. The validator (or track) is shared across all entries.
The each.values array can also contain objects, accessed via dot notation:
"input": {
"each": {
"var": "data",
"values": [
{ "category": "electronics", "type": "phones" },
{ "category": "computers", "type": "laptops" }
]
},
"params": { "url": "https://example.com/{{data.category}}/type/{{data.type}}" }
}
flatten)By default, when a rule has multiple input entries, all fetched items are merged into a single flat list. Set "flatten": false on the rule to keep items grouped by input entry — items becomes a nested list (list of lists), one group per input.
{
"ref": "wiki",
"name": "wiki-monitoring",
"flatten": false,
"input": [
{ "params": { "page": "SEO" } },
{ "params": { "page": "UKEN" } }
],
...
}
In the template, iterate over groups and items within each group:
{% for group in items %}
{% assign first = group | first %}
============================================================
{{ first._input.page }}
============================================================
{% for item in group %}
{{ item.title }}
{% endfor %}
{{ first._search_url }}
{% endfor %}
Each item in grouped mode gets additional metadata:
| Variable | Description |
|---|---|
{{ item._search_url }} | The rendered URL for that item's input entry |
{{ item._input }} | The params object for that item's input entry (e.g. {{ item._input.page }}) |
{{ item.index }} | Global 1-based index spanning all groups |
{{ count }} is the total number of items across all groups. {{ search_url }} is the URL of the first input entry.
When flatten is true (default) or there is only one input entry, the template works as usual with a flat items list.
Empty groups are automatically filtered out — if an input produces zero items, it won't appear in the template.
ref per input)To combine results from different definitions into a single email, give each input entry its own ref (overriding the rule's default) plus an optional friendly label. Each item gets _label for use in the template.
{
"name": "python-jobs",
"schedule": "0 9 * * *",
"subject": "[Jobs] {{count}} new Python offer(s)",
"template": "./templates/jobs-aggregated",
"email": "you@example.com",
"flatten": false,
"dedupe": ["title", "company"],
"input": [
{
"ref": "justjoin",
"label": "justjoin.it",
"params": {"keyword": "Python"},
"validator": {"@id": "job-board-python"}
},
{
"ref": "pracuj",
"label": "pracuj.pl",
"params": {"url": "https://..."}
},
{
"ref": "nofluffjobs",
"label": "nofluffjobs.com",
"params": {"language": "Python"},
"validator": {"@id": "job-board-python"}
}
]
}
Template using _label:
{% for group in items %}
=== {{ group[0]._label }} ({{ group.size }}) ===
{% for item in group %}
{{ forloop.index }}. {{ item.title }} — {{ item.company }}
{% endfor %}
{% endfor %}
When inputs use different refs, IDs are automatically namespaced as <ref>:<id> to prevent state collisions. dedupe still applies across all sources, so the same listing appearing on multiple sites is collapsed.
The rule's top-level ref becomes optional in this mode — either provide it as a fallback default, or omit it and require every input to specify its own.
Each input entry can have a validator object that filters extracted items. The validator supports two condition types. If both are present, both must pass (AND logic).
test -- ExpressionA general-purpose expression evaluated by expression-py. Supports arithmetic, comparisons, regex matching with =~, boolean operators, array operations, and Liquid variable placeholders. Item variables are available directly by name (no {{ }} needed); use Liquid placeholders only when you need a Liquid filter or custom command.
"validator": {
"test": "price > 9.5"
}
Supported operations:
| Operator | Example |
|---|---|
| Comparison | price > 10, change_pct <= -5 |
| AND / OR | (price > 80) && (change_pct < 0), (price < 5) || (price > 100) |
| Arithmetic | price * quantity > 1000 |
| Regex match | title =~ /^Ask HN/, title =~ /wikipedia/i |
| Capture groups | After title =~ /Senior (.+)/, $1 holds the first group |
| Array membership | "Python" in skills, skills & ["AI", "ML"] |
| Array equality | skills & ["Angular", "Java"] == [] (none of these match) |
| Null check | salary_from != null |
Empty arrays are falsy, so skills & ["AI", "ML"] directly evaluates to true/false: true if skills contains "AI" or "ML", false otherwise. See expression-py docs for the full operator list and precedence.
You can still use {{ }} placeholders when you need Liquid pre-processing (e.g. filters or custom commands):
{ "test": "{{ date | date: \"%Y%m%d\" }} == {{ \"now\" | date: \"%Y%m%d\" }}" }
{ "test": "{% fresh date 604800 %}" }
match -- List membership matchChecks whether a variable's value is in (or not in) a list of strings. For regex matching, use test with the =~ operator instead.
"validator": {
"match": {
"var": "skills",
"exclude": ["Angular", "C#", ".NET", "Java"]
}
}
Match condition fields:
| Field | Required | Description |
|---|---|---|
var | one of var or value | Direct variable name — returns the raw value, preserving lists from collect: true |
value | one of var or value | Liquid template string rendered against item variables (always produces a string) |
include | one of include or exclude | Array of strings — passes if any string is found (see below) |
exclude | one of include or exclude | Array of strings — passes if none are found (see below) |
strict | no | When true, include/exclude use exact string equality instead of substring match. Only affects string values — list values always use exact element matching. Default false. |
When the variable is a list (from collect: true), each element is compared as an exact match — "Java" matches the skill "Java" but not "JavaScript". For plain string values, substring matching is used by default; set strict: true for exact equality.
match can also be an array of match objects (AND logic — all must pass):
"validator": {
"match": [
{ "var": "skills", "include": ["Python"] },
{ "var": "skills", "exclude": ["Angular", "C#"] }
]
}
Both conditions must pass (AND logic within a single object):
"validator": {
"test": "price > 80 && company =~ /Asseco/",
"match": {
"var": "skills",
"exclude": ["Java", "C#"]
}
}
The validator can also be an array. The item is included if any validator in the array passes. This is useful for defining price thresholds or notification steps:
"validator": [
{ "test": "{{price}} > 8" },
{ "test": "{{price}} > 9" },
{ "test": "{{price}} > 9.5" }
]
Each entry in the array is a full validator object that can use test, match, or both.
require)In a validator array, set "require": true to make a validator mandatory. Required validators must ALL pass (AND logic), while the remaining validators use OR logic (at least one must pass). If only required validators exist, the OR check is skipped.
This is useful for combining a baseline filter with threshold alerts:
"validator": [
{ "require": true, "test": "{{score_num}} > 50" },
{ "test": "{{price}} > 75000" },
{ "test": "{{price}} > 80000" },
{ "test": "{{price}} > 100000" }
]
The require validator acts as a gate — items must pass it before the OR thresholds are even considered.
@id)Define shared validators in defs.validators and reference them by name using {"@id": "name"}. This eliminates duplication when multiple rules use the same filter:
"defs": {
"validators": {
"job-board": {
"require": true,
"match": [
{"var": "title", "exclude": ["Angular", "C#", ".NET"]},
{"var": "skills", "exclude": ["Angular", "C#", ".NET", "Java"]}
]
}
}
}
Then reference it in rules:
"input": {
"validator": {"@id": "job-board"}
}
@id references work anywhere a validator is expected — as a standalone validator, or as an element in a validator array:
"validator": [
{"@id": "job-board"},
{ "require": true, "test": "!(salary =~ /Undisclosed/)" }
]
Commands are reusable Liquid tags defined in defs.commands. Each command becomes a custom {% tag %} that can be used in validator test and match expressions, replacing verbose Liquid expressions with short, readable tags.
Commands are defined in the commands key under defs:
"defs": {
"commands": {
"fresh": {
"args": ["field", "seconds"],
"template": "{{ field | date: \"%s\" }} > {{ \"now\" | date: \"%s\" | minus: seconds }}"
},
"today": {
"args": ["field"],
"template": "{{ field | date: \"%Y%m%d\" }} == {{ \"now\" | date: \"%Y%m%d\" }}"
}
},
...
}
| Field | Required | Description |
|---|---|---|
args | no | Ordered list of argument names. Values are passed positionally when the tag is used. |
template | yes | Liquid template string rendered with bound arguments. Argument names are available as variables. |
Use commands as {% name arg1 arg2 %} in any validator test or match expression:
"validator": {
"test": "{% fresh date 604800 %}"
}
This is equivalent to writing the full Liquid expression:
"test": "{{ date | date: \"%s\" }} > {{ \"now\" | date: \"%s\" | minus: 604800 }}"
Arguments are matched positionally to the args list in the command definition. Word arguments (like date) are resolved as variables from the item context. Numeric arguments (like 604800) are passed as literal values.
The skeleton config includes two commands:
{% fresh <field> <seconds> %} — checks whether a date field is newer than a given number of seconds. Useful for filtering stale items from feeds that return non-deterministic results:
"input": {
"validator": {
"test": "{% fresh date 604800 %}"
}
}
This filters out any items where the date field is older than 7 days (604800 seconds).
{% today <field> %} — checks whether a date field matches today's date:
"input": {
"validator": {
"require": true,
"test": "{% today date %}"
}
}
Custom Liquid filters defined in defs.filters. Each key becomes a filter usable as {{ value | name }} in templates. Filters are defined using standard Liquid filter expression syntax — the input value is piped through the expression chain.
"defs": {
"filters": {
"clean": "replace_regex: '\\s+', ' ' | strip"
}
}
The expression uses standard Liquid pipe syntax. Built-in Liquid filters (strip, downcase, replace, etc.) and the additional replace_regex filter are available:
| Filter | Description |
|---|---|
replace_regex: pattern, replacement | Regex substitution (supports backreferences \1, \2, etc.) |
html2text | Convert HTML to plain text, preserving code blocks and link URLs |
Filters can be chained with | — the output of one becomes the input of the next. Custom filters can also reference other custom filters defined earlier.
Use filters with the standard Liquid pipe syntax in any template:
{{ item.snippet | clean }}
The clean filter above collapses all whitespace (newlines, tabs, spaces) into a single space and trims leading/trailing whitespace.
Two pagination types are supported:
next_link -- Follow a "next" linkFor sites with a single "More" or "Next" link (e.g. Hacker News):
"pagination": {
"type": "next_link",
"selector": "a.morelink",
"base_url": "https://news.ycombinator.com/",
"max_pages": 2
}
numbered -- Follow numbered page buttonsFor sites with numbered pagination (e.g. useme.com):
"pagination": {
"type": "numbered",
"selector": ".pagination .pagination__page",
"active_class": "pagination__page--active",
"base_url": "https://useme.com/pl/jobs/",
"max_pages": 5
}
Finds the active page button and follows the link of the next one.
| Field | Required | Description |
|---|---|---|
max_pages | no | Maximum number of pages to fetch (default: 1) |
base_url | no | Base URL for resolving relative href values |
Each rule can have a schedule field with a standard cron expression or an array of expressions (any match triggers the rule). The script is designed to be invoked frequently (e.g. every 5 minutes via system cron), and it decides internally which rules are due based on their schedule.
The schedule uses croniter to parse standard 5-field cron expressions:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
│ │ │ │ │
* * * * *
| Expression | Meaning |
|---|---|
0 8 * * * | Daily at 8:00 |
0 */6 * * * | Every 6 hours (0:00, 6:00, 12:00, 18:00) |
0 9 * * 1 | Every Monday at 9:00 |
*/30 * * * * | Every 30 minutes |
0 8,20 * * * | Twice daily at 8:00 and 20:00 |
When a single cron expression can't cover your needs, use an array. The rule runs if any expression matches:
"schedule": ["0,30 9 * * *", "0 16 * * *"]
This runs at 9:00, 9:30, and 16:00 — something not expressible in a single 5-field cron string.
The script is designed to be invoked periodically by system cron (e.g. every 5 minutes or every hour). On each invocation:
croniter.match~/.mutimon/data/.lastrun_<rule_name> to prevent duplicate runs if the script is triggered again within the same minute--force to bypass all schedulesTemplates use Liquid syntax via python-liquid. The following variables are available:
| Variable | Description |
|---|---|
{{ count }} | Number of new items |
{{ now }} | Current date and time |
{{ search_url }} | The rendered URL from the definition |
{% for item in items %} | Loop over new items |
{{ item.index }} | 1-based position within the items list |
Any rule params | e.g. {{ query }} |
| Any extracted variable | e.g. {{ item.title }}, {{ item.url }}, {{ item.score }} |
Liquid supports conditionals, filters, and logic — see the Liquid docs.
Hacker News - New Stories
Checked at: {{ now }}
Number of new stories: {{ count }}
============================================================
{% for item in items %}
{{ item.rank }} {{ item.title }}
Score: {{ item.score }} point{% if item.score != 1 %}s{% endif %} | {{ item.age }}
URL: {{ item.url }}
HN: {{ item.comments_url }}
{% endfor %}
============================================================
The subject field in a rule is also a Liquid template with access to the same variables.
parse: "money", the page language is detected from <html lang> or the Content-Language header, and used for locale-aware currency parsing via babel~/.mutimon/data/<rule_name>_valid flag (or _state index for track mode), so threshold crossings are detected on subsequent runsWhen a rule has validators, the scraper tracks whether each item passed or failed on the previous run. This enables re-notifications when a value crosses a threshold boundary:
>= 75000 passes → notify, save _valid: true_valid: false_valid was false → notify again_valid was true → no notificationThis works for both upward thresholds (>=) and downward thresholds (<=). The state file stores all fetched items (not just those passing the validator) with a _valid boolean.
track)For more granular threshold monitoring, use track instead of validator on an input entry. While validator stores a single pass/fail boolean, track implements a state machine that tracks which threshold an item is in and notifies on every state transition.
track and validator are mutually exclusive on the same input entry.
"input": {
"params": { "symbol": "ASSECOPOL" },
"track": {
"value": "{{price}}",
"states": [
{ "test": "{{price}} > 200", "name": "above 200 zł" },
{ "test": "{{price}} > 190", "name": "above 190 zł" },
{ "test": "{{price}} > 180", "name": "above 180 zł" },
{ "test": "{{price}} <= 180", "silent": true }
]
}
}
| Field | Required | Description |
|---|---|---|
value | no | Liquid expression to evaluate and save as _value for templates. |
states | yes | Array of state definitions, evaluated top-down. First matching state wins. |
states[].test | yes | expression-py expression with optional Liquid variables. |
states[].name | no | Human-friendly label, available as {{ item._state_name }} in templates. Defaults to the test expression. |
states[].silent | no | If true, transitioning to this state saves state but does not trigger a notification. Default false. |
States are evaluated top-down — the first matching test expression determines the item's current state (by index). On each run:
silentsilent"track": {
"value": "{{price}}",
"states": [
{ "test": "{{price}} > 200", "name": "above 200 zł" },
{ "test": "{{price}} > 190", "name": "above 190 zł" },
{ "test": "{{price}} > 180", "name": "above 180 zł" },
{ "test": "{{price}} <= 180", "silent": true }
]
}
| Run | Price | State | Previous | Notify? |
|---|---|---|---|---|
| 1 | 185 | above 180 zł (2) | — | Yes (new) |
| 2 | 188 | above 180 zł (2) | above 180 zł (2) | No (same state) |
| 3 | 195 | above 190 zł (1) | above 180 zł (2) | Yes (crossed 190) |
| 4 | 170 | silent (3) | above 190 zł (1) | No (silent) |
| 5 | 185 | above 180 zł (2) | silent (3) | Yes (came back) |
| 6 | 195 | above 190 zł (1) | above 180 zł (2) | Yes (crossed 190 again) |
In track mode, the following variables are available in templates:
| Variable | Description |
|---|---|
{{ item._state_name }} | Name (or test expression) of the current state |
{{ item._prev_state_name }} | Name of the previous state (or empty for new items) |
{{ item._value }} | Rendered value from track.value (e.g. the current price) |
track vs validatorvalidator — binary filter: include/exclude items. Good for "notify me about new Hacker News posts with score > 100" or "exclude job offers with Angular".track — state machine: notify on every threshold crossing. Good for "notify me each time ASSECOPOL crosses above 190 zł, and again when it crosses above 200 zł".Definitions without a query section act as health checks. Instead of parsing HTML/XML/JSON, Mutimon makes an HTTP request and returns a single item with response metadata. This is useful for monitoring website uptime.
When query is omitted, the returned item has an http object with the following fields:
| Variable | Type | Description |
|---|---|---|
{{ http.code }} | int | HTTP status code (0 for connection errors) |
{{ http.method }} | string | Request method used (GET, HEAD, POST) |
{{ http.body }} | string | Response body |
{{ http.headers }} | dict | Response headers (all keys lowercase) |
{{ http.response_time }} | float | Response time in seconds |
{{ http.error }} | string/null | Error message on connection failure, null on success |
The item's id is set to the requested URL automatically.
Definition (reusable for any URL):
{
"defs": {
"health": {
"url": "{{ url }}"
}
}
}
Rule with track for up/down state notifications:
{
"name": "my-sites",
"ref": "health",
"schedule": "*/15 * * * *",
"input": [
{ "params": { "url": "https://example.com/" } },
{ "params": { "url": "https://api.example.com/" } }
],
"track": [
{ "name": "down", "test": "({{ http.code }} >= 400) | ({{ http.code }} == 0)" },
{ "name": "up", "test": "{{ http.code }} >= 200", "silent": true }
],
"subject": "Site down: example.com",
"template": "./templates/health",
"email": "you@example.com"
}
With "silent": true on the "up" state, you only get notified when a site goes down. Remove "silent" to also get notified when it recovers.
Example template (~/.mutimon/templates/health):
Health check at {{ now }}
============================================================
{% for item in items %}
{{ item.url }}
Status: {{ item._state_name }} (HTTP {{ http.code }})
Response time: {{ http.response_time }}s{% if http.error %}
Error: {{ http.error }}{% endif %}
{% endfor %}
Use match with a value Liquid template to check response headers:
{
"validator": {
"match": {
"value": "{{ http.headers['content-type'] }}",
"regex": "^application/json"
}
}
}
Header names are always lowercase in the http.headers dict, regardless of how the server returns them.
Definitions can include a validator and/or track that act as defaults for all rules referencing that definition. This avoids repeating the same filter across multiple rules or input entries.
A definition-level validator is AND-merged with input-level validators. The def validator becomes a require: true gate — items must pass it before the input-level validators are considered.
"defs": {
"atom": {
"url": "{{feed_url}}",
"format": "xml",
"query": { ... },
"validator": { "test": "{% fresh date 604800 %}" }
}
}
Any rule using the atom def automatically filters out items older than 7 days. If a rule adds its own validator (e.g. a title regex), both must pass — freshness AND the title match.
When an input entry has its own track, the def-level validator is ignored (since track and validator are mutually exclusive).
A definition-level track provides a default state machine for all rules. Input-level track overrides the def-level track entirely (no merging).
"defs": {
"health": {
"url": "{{ url }}",
"track": {
"states": [
{ "name": "down", "test": "({{ http.code }} >= 400) | ({{ http.code }} == 0)" },
{ "name": "up", "test": "{{ http.code }} >= 200", "silent": true }
]
}
}
}
Rules using the health def get the default up/down tracking. An input entry can override with a custom track (e.g. a CORS proxy that returns 400 normally):
"input": [
{ "params": { "url": "https://example.com/" } },
{
"params": { "url": "https://proxy.example.com/" },
"track": {
"states": [
{ "name": "down", "test": "({{ http.code }} >= 500) | ({{ http.code }} == 0)" },
{ "name": "up", "test": "{{ http.code }} > 0", "silent": true }
]
}
}
]
The first entry uses the def's default track. The second overrides it with a custom threshold.
When an input entry has its own validator, the def-level track is ignored.
The scraper sends error emails for four types of failures. The error email function (send_error_email) uses only Python's standard library (no third-party deps), so it works even when the error is caused by a missing dependency.
| Error | Email subject | Behavior |
|---|---|---|
Missing dependency (e.g. import liquid fails) | [mutimon] Missing dependency | Sends traceback, exits |
| Invalid config (schema validation fails) | [mutimon] Invalid configuration | Sends all validation errors, exits |
HTML structure change (expect selectors missing) | [mutimon] HTML structure changed for '<rule>' | Sends missing selectors, skips that input, continues other rules |
Fatal runtime crash (unhandled exception in main()) | [mutimon] Fatal error | Sends full traceback |
Error emails are sent to all unique recipient addresses found across all rules in the config.
The skeleton/ directory contains ready-to-use examples that are copied to ~/.mutimon/ on first run.
Monitors the Hacker News front page for new stories. Uses pagination to fetch 2 pages (60 stories), sibling element extraction for scores, and data-test attribute-based IDs.
Files: skeleton/config.json (hackernews def + rule), skeleton/templates/hackernews
Monitors Bitcoin price on CoinMarketCap with threshold-based alerts. Demonstrates:
$70,528.40 is correctly parsed as 70528.40 using parse: "money" (US English format detected from <html lang="en">)expect field checks that [data-test='text-cdp-price-display'] exists on the pageFiles: skeleton/config.json (coinmarketcap def + rule), skeleton/templates/coinmarketcap
The bitcoin rule uses two input entries — one for upward thresholds (>=), one for downward thresholds (<=):
"input": [
{
"params": { "coin": "bitcoin" },
"validator": [
{ "test": "{{price}} >= 75000" },
{ "test": "{{price}} >= 80000" },
{ "test": "{{price}} >= 100000" }
]
},
{
"params": { "coin": "bitcoin" },
"validator": [
{ "test": "{{price}} <= 60000" },
{ "test": "{{price}} <= 50000" }
]
}
]
Tip: For more granular notifications (e.g. notify each time the price crosses a specific level, not just when it re-enters a "passing" state), use
trackinstead ofvalidator.
Monitors stock prices with per-threshold notifications using track. Unlike the Bitcoin example which uses validator (binary pass/fail), track notifies on every state transition — e.g. when a stock crosses above 190 zł, then again when it crosses 200 zł.
"input": [
{
"params": { "symbol": "ASSECOPOL" },
"track": {
"value": "{{price}}",
"states": [
{ "test": "{{price}} > 200", "name": "above 200 zł" },
{ "test": "{{price}} > 190", "name": "above 190 zł" },
{ "test": "{{price}} > 180", "name": "above 180 zł" },
{ "test": "{{price}} <= 180", "silent": true }
]
}
}
]
The silent state at the bottom acts as a "reset" — when the price drops below 180, no notification is sent, but the state is saved. When the price rises back above 180, it's detected as a state change and triggers a new notification.
Monitors a Reddit subreddit via its Atom feed (Reddit serves .rss URLs as Atom XML). Demonstrates:
format: "xml" switches from HTML to XML parsing, so CSS selectors target XML elements (entry, title, link) instead of HTMLsubreddit param lets the same definition monitor any subredditentry for items, link[href] for URLs (Atom uses <link href="..."/> instead of <link>text</link>)Files: skeleton/config.json (reddit-atom def + rule), skeleton/templates/reddit
"reddit-atom": {
"params": ["subreddit"],
"format": "xml",
"userAgent": "Liferea/1.15.6 (Linux; https://lzone.de/liferea/) AppleWebKit (KHTML, like Gecko)",
"url": "https://www.reddit.com/r/{{subreddit}}.rss",
"query": {
"type": "list",
"selector": "entry",
"id": { "source": "entry_id" },
"variables": {
"title": { "selector": "title", "value": { "type": "text" } },
"url": { "selector": "link", "value": { "type": "attribute", "name": "href" } },
"entry_id": { "selector": "id", "value": { "type": "text" } },
"date": { "selector": "updated", "value": { "type": "text" }, "default": "" },
"author": { "selector": "author name", "value": { "type": "text" }, "default": "" }
}
}
}
eachMonitors multiple subreddits for posts about hiring Python or JavaScript developers. Demonstrates:
each expansion: a single input object expands into multiple fetches, one per subreddit — no need to repeat the same params/validator for eachdefs.validators defines a named validator referenced by @id across all expanded entriesDefinition: uses the same reddit-atom def from the previous example.
Reusable validator:
"validators": {
"reddit-hiring": [
{ "test": "{% fresh date 604800 %}", "require": true },
{ "test": "title =~ /\\b(hiring|hire|looking for)\\b.*(Python|JavaScript)/i" }
]
}
Rule:
{
"ref": "reddit-atom",
"name": "reddit-hiring-dev",
"schedule": "0 */4 * * *",
"subject": "[Reddit] {{count}} new hiring post(s)",
"template": "./templates/reddit",
"email": "you@example.com",
"input": {
"each": { "var": "subreddit", "values": ["Python", "JavaScript"] },
"params": { "subreddit": "{{subreddit}}" },
"validator": { "@id": "reddit-hiring" }
}
}
Files: skeleton/config.json (reddit-atom def + rule), skeleton/templates/reddit
Mutimon ships with an AI instruction file that teaches any AI assistant how to add websites. Get its path with:
mon --ai-guide
Use it with Claude Code in batch mode:
claude -p "$(mon --ai-guide) Add https://github.com/trending to mutimon. Extract repo name, description, URL, language, and stars. Email me daily at 8am at user@example.com."
Or with any AI assistant — just paste the output of mon --ai-guide as context along with your request.
Add a rule to monitor Hacker News (https://news.ycombinator.com) for new stories. Extract the title, URL, score, and age. Send me an email every 6 hours at user@example.com with the new stories. Read the AI guide with
mon --ai-guidefor config format reference.
Add Bitcoin price monitoring using https://coinmarketcap.com/currencies/bitcoin/. Notify me when the price crosses above $75,000 or drops below $60,000. Check every 4 hours. Send alerts to user@example.com. Read the AI guide with
mon --ai-guidefor config format reference.
Monitor ASSECOPOL stock on https://www.bankier.pl/inwestowanie/profile/quote.html?symbol=ASSECOPOL. Use
track(notvalidator) to notify me each time the price crosses above 180, 190, or 200 zł. Add a silent state for below 180 so I only get notified when it rises back above thresholds. Check twice daily during market hours. Read the AI guide withmon --ai-guidefor config format reference.
Monitor https://soloterm.com/download for Linux support. The page currently shows "Coming soon" next to Linux. Notify me when that label disappears (use the match validator with exist: false). Also add an expect check so I get an error email if the page structure changes. Read the AI guide with
mon --ai-guidefor config format reference.
Add a rule to monitor the r/scheme subreddit via its RSS feed at https://www.reddit.com/r/scheme.rss. Reddit serves Atom XML, so use format "xml" and a Liferea User-Agent. Extract the title, URL, author, and date. Check every 6 hours and email me at user@example.com. Read the AI guide with
mon --ai-guidefor config format reference.
Add a rule to monitor Hacker News for "Ask HN" posts only. Use the existing hackernews definition with a match validator that filters titles starting with "Ask HN". Read the AI guide with
mon --ai-guidefor config format reference.
Add a rule to monitor the r/scheme subreddit via its Atom feed. Use the
{% fresh date 604800 %}command to filter out posts older than 7 days, since Reddit's feed sometimes returns stale posts. Read the AI guide withmon --ai-guidefor config format reference.
eachMonitor r/Python and r/JavaScript subreddits for posts about hiring developers. Use the
eachinput expansion to avoid duplicating the input entry for each subreddit. Filter titles containing "hiring", "hire", or "looking for" combined with "Python" or "JavaScript". Use a reusable validator indefs.validators. Check every 4 hours. Read the AI guide withmon --ai-guidefor config format reference.
Add a rule to monitor job offers on https://it.pracuj.pl. The site is a Next.js app — some data (like city-specific URLs for multi-location offers) is only in the
<script id="__NEXT_DATA__">JSON, not in the HTML. Useparse: "json"with a JMESPath query to extract city and URL from the embedded JSON. Read the AI guide withmon --ai-guidefor config format reference.
Add a health check rule to monitor https://example.com and https://api.example.com. Use a definition without
queryto get HTTP response metadata. Usetrackstates to notify when a site goes down (status >= 400 or connection error) and stay silent when it's up. Check every 15 minutes. Read the AI guide withmon --ai-guidefor config format reference.
Add a rule to monitor a Wikipedia discussion page for new threads. The page uses MediaWiki DiscussionTools where thread content is spread across sibling elements, not nested in a container. Use
findwithuntilto collect siblings between headings and reply buttons,transformto strip signatures and UI elements, andtype: "html"with thehtml2textLiquid filter to convert the content to plain text in the template. Read the AI guide withmon --ai-guidefor config format reference.
The logo was created as a combination of clipart from OpenClipart:
It also uses Lovelo font.
Mutimon is a concise Latin portmanteau formed from mutare (“to change”) + monere (“to warn / monitor”).
Copyright (C) 2026 Jakub T. Jankiewicz
Released under GPL-3.0 license
100 commits
Python
99.8%
A generic, config-driven web scraper that monitors websites for changes and sends email notifications. Define what to scrape using CSS selectors in a JSON config file, and format notifications with Liquid templates.
Designed to run as a cron job. Each rule has its own schedule (cron expression), so the script can be invoked frequently (e.g. every 5 minutes) and each rule runs only when its schedule is due.
Configuration works best with AI agents like OpenClaw, ClaudeCode, OpenCode, or Codex.
pip install mutimon
This installs the mon command.
git clone https://github.com/jcubic/mutimon.git
cd mutimon
pip install .
This installs the mon command from the local source, including all dependencies.
On the first run, the tool creates ~/.mutimon/ with a skeleton config and example rules (Hacker News + Bitcoin price alerts):
mon
# Config not found at /home/user/.mutimon/config.json
# Creating skeleton configuration in /home/user/.mutimon...
# Done. Edit /home/user/.mutimon/config.json to configure your scraping rules.
Edit ~/.mutimon/config.json with your SMTP credentials and scraping rules, then run again.
mon # process rules; only prints notifications and errors
mon --force # ignore schedules, run all rules now
mon --force <rule> ... # ignore schedule, run only the named rule(s)
mon --init # seed state for all rules without sending notifications
mon --init <rule> ... # seed state for specific rule(s) without sending notifications
mon --reset <rule> ... # delete all stored data (state, last-run, saved email) for the named rule(s)
mon --dry-run # fetch and display data, bypass schedules, no state changes
mon --save-email # save email to file instead of sending via SMTP
mon --validate # validate config against schema and exit
mon --list # list all rule names (usable with --force <rule>)
mon --ai-guide # print the AI instruction guide for adding websites
mon --cron # print a cron entry with resolved path (default: every 5 min)
mon --cron "0 8 * * *" # print a cron entry with a custom schedule
mon --completion bash # output shell completion script (bash, zsh, or fish)
mon -v, --verbose # show detailed progress (page fetches, counts, skipped rules)
mon -q, --quiet # suppress all output including errors
Use --cron to generate a cron entry with the correct resolved path (works with pyenv, virtualenvs, etc.):
mon --cron # default: every 5 minutes
mon --cron "0 * * * *" # custom: every hour
Install it directly:
(crontab -l 2>/dev/null; mon --cron) | crontab -
Each rule's schedule field controls when it actually executes, so running mon frequently (e.g. every 5 minutes) is safe — rules only fire when their cron expression matches.
--init)Use --init to populate state files without sending any notifications. This is useful when adding new rules — without --init, the first run would send emails for all existing items on the page:
mon --init # seed state for all rules
mon --init my-rule # seed state for a specific rule
Like --force, --init bypasses schedules. All items are fetched, validated, and saved to state, but no emails are sent.
--reset)Use --reset to delete all stored data for one or more rules — the state file, the last-run timestamp, and any saved email. The next run then treats every item as new:
mon --reset my-rule # clear one rule
mon --reset rule-a rule-b # clear several rules
At least one rule name is required (there is deliberately no "reset everything" form). Unknown rule names are rejected with an error, and nothing is fetched or sent.
Tab completion is available for bash, zsh, and fish. It completes long options and rule names for --force, --init, and --reset.
Generate the completion script:
mon --completion bash # or zsh, fish
Install for your shell:
# bash — add to ~/.bashrc
eval "$(mon --completion bash)"
# zsh — add to ~/.zshrc
eval "$(mon --completion zsh)"
# fish — add to ~/.config/fish/config.fish
mon --completion fish | source
~/.mutimon/
config.json # main configuration
templates/ # Liquid email templates
hackernews
data/ # state files (tracked items per rule)
hackernews
.lastrun_hackernews # last run timestamp for schedule tracking
emails/ # saved copies of sent emails
logs/ # per-rule debug logs (when log: true)
rule_name.log
A JSON Schema is provided for editor autocompletion and validation. Add "$schema": "./config.schema.json" to your config file, or point to the raw URL if hosted on GitHub.
The config is validated against the schema on every run. If the config is invalid, an error email with all validation errors is sent to all rule recipients and the script exits.
The config file (~/.mutimon/config.json) has three sections:
email -- SMTP server"email": {
"server": {
"host": "smtp.example.com",
"port": 587,
"password": "your-password",
"email": "you@example.com"
}
}
defs -- Reusable scraping definitions and commandsEach definition describes how to fetch and parse data from a website. The optional commands key defines reusable Liquid tag commands (see Commands).
"hackernews": {
"url": "https://news.ycombinator.com",
"pagination": { ... },
"query": {
"type": "list",
"selector": "tr.athing.submission",
"id": { ... },
"filter": { ... },
"variables": { ... }
}
}
Fields:
| Field | Required | Description |
|---|---|---|
url | yes | URL to fetch. Supports Liquid variables from rule params, e.g. https://example.com?q={{query}} |
format | no | "html" (default) or "xml". Use "xml" for RSS/Atom feeds or any XML document. Switches BeautifulSoup to the lxml XML parser (requires lxml). |
userAgent | no | Custom User-Agent header. If omitted, a default browser-like User-Agent is used. Useful for RSS feeds or APIs that require a specific User-Agent. |
params | no | List of parameter names used in the URL template |
pagination | no | Pagination config (see below) |
query | no | When omitted, the definition acts as a health check — returns HTTP response metadata instead of parsing content. |
query.type | yes* | "list" (multiple items) or "single" (one item). *Required when query is present. |
query.selector | yes* | CSS selector for item container(s). For XML, use XML element names (e.g. item for RSS, entry for Atom). *Required when query is present. |
validator | no | Default validator applied to all rules using this definition. AND-merged with input-level validators (see Definition-level validator and track). |
track | no | Default track applied to all rules using this definition. Overridden by input-level track (see Definition-level validator and track). |
query.id | no | How to extract a unique ID per item (see below) |
query.filter | no | Filter to exclude items (see below) |
query.expect | no | List of CSS selectors that must exist on the page (see Expected structure). Sends error email if missing. |
query.reject | no | List of CSS selectors that indicate no real results (see Reject selectors). Returns 0 items if any match. |
query.variables | yes | Named fields to extract (see below) |
rules -- What to runEach rule references a definition and can override params, email recipient, template, etc.
{
"ref": "hackernews",
"name": "hackernews",
"schedule": "0 */6 * * *",
"subject": "Hacker News: {{count}} new stories",
"template": "./templates/hackernews",
"email": "you@example.com"
}
Fields:
| Field | Required | Description |
|---|---|---|
ref | conditional | Name of the definition in defs. Optional when every input entry specifies its own ref (aggregation pattern, see Aggregating multiple sources). |
name | yes | Unique rule name. Used for state file (~/.mutimon/data/<name>) |
schedule | no | Cron expression or array of expressions (see Schedule). If omitted, runs every time. |
subject | yes | Liquid template for the email subject line |
template | yes | Path to the Liquid template file (relative to ~/.mutimon/) |
email | yes | Recipient email address |
params | no | Values for the definition's URL template variables. Used when input is not specified. |
input | no | One or more input entries with params and optional validators (see Multiple inputs). Overrides params. |
flatten | no | When true (default), items from all inputs are merged into a flat list. When false, items is a nested list grouped by input entry (see Grouped items). |
dedupe | no | Array of variable names to use as a composite dedup key. Items with identical values for all listed fields are collapsed (first occurrence kept). See Field-based deduplication. |
enabled | no | Set to false to disable a rule without removing it from the config. Disabled rules are skipped on every run. Default true. |
log | no | When true, write per-run debug logs to ~/.mutimon/logs/<rule_name>.log with timestamps, item counts, notification decisions, and returning-ID detection. |
Each variable in query.variables defines how to extract a value from a matched element:
"title": {
"selector": ".titleline > a",
"value": {
"type": "text"
}
}
| Type | Description | Extra fields |
|---|---|---|
text | Inner text of the element | |
attribute | HTML attribute value | name -- attribute name (e.g. "href") |
html | Raw inner HTML of the element | Use the html2text Liquid filter in templates to convert to plain text |
| Field | Description |
|---|---|
regex | Extract a capture group from the raw value. Uses group(1) if available. |
prefix | String prepended to the final value. Useful for turning relative URLs into absolute. |
parse | Convert the extracted string to a typed value. "number": plain numeric parsing for integers and floats, strips commas as thousands separators (e.g. "1,234" -> 1234, "3.14" -> 3.14). "money": locale-aware currency parsing via babel, auto-detects page language from <html lang> or Content-Language header, strips currency symbols and percent signs, handles US ($70,528.40), European (11,8000 zł), and mixed (11.800,50 €) formats. "list": split the value into a list using the delimiter regex (default \s*,\s*), use {% for x in item.field %} in templates. "json": parse the value as JSON, then optionally extract structured data with query (see JSON extraction). "url": URL normalization using urljoin — when combined with prefix, the value is resolved as a relative URL against the prefix base (e.g. prefix: "https://example.com" + value "/page?id=1" → "https://example.com/page?id=1"). Unlike plain prefix which concatenates strings, "url" handles relative paths, query strings, and fragments correctly. "date": normalize a (possibly localized) date string to ISO 8601 ("YYYY-MM-DD") using the page's detected locale for month names, so date commands like {% today %} / {% fresh %} work on non-English dates (e.g. the Polish "10 lipca" → "2026-07-10", "10 lip 2026" → "2026-07-10"); a missing year defaults to the current year, and unparseable values pass through unchanged. Parsed values are used by validators. |
delimiter | Regex pattern used to split the value when parse is "list". Defaults to \s*,\s* (comma with optional surrounding whitespace). |
query | Only for parse: "json". Defines how to navigate and extract variables from the parsed JSON using JMESPath (see JSON extraction). |
| Field | Description |
|---|---|
default | Fallback value if the selector doesn't match or the value is empty |
sibling | When true, search the next sibling element instead of within the matched element. Needed when data is split across adjacent HTML elements (e.g. Hacker News stores title and score in separate <tr> rows). |
collect | When true, collect ALL matching elements (using select() instead of select_one()). Returns a list that can be iterated in templates with {% for skill in item.skills %}. Useful for extracting lists of tags, skills, or categories from repeated elements. |
find | Array of chainable DOM traversal steps for advanced element navigation. See DOM traversal. |
transform | Array of DOM mutation steps applied before value extraction. See DOM transformation. |
| Selector | Description |
|---|---|
:self | References the container element itself instead of searching for a child. Useful when the container is an <a> tag and you need its href attribute. |
"url": {
"selector": "a.job__title-link",
"value": {
"type": "attribute",
"name": "href",
"regex": "^(/.*)",
"prefix": "https://useme.com"
}
}
This selects the href attribute from a.job__title-link, extracts the path with a regex, then prepends the domain.
When an item contains repeated elements (e.g. skill tags, categories), use collect: true to extract all matches as a list:
"skills": {
"selector": ".skill-tag",
"value": { "type": "text" },
"collect": true
}
This finds all .skill-tag elements inside the item container and returns a list like ["TypeScript", "React", "Node.js"]. Use a loop in the template:
{% for skill in item.skills %}{{ skill }}{% unless forloop.last %}, {% endunless %}{% endfor %}
When the container element itself holds the data you need (e.g. an <a> tag with an href), use :self:
"url": {
"selector": ":self",
"value": {
"type": "attribute",
"name": "href",
"prefix": "https://example.com"
}
}
find)For complex pages where the data you need isn't inside the item container, use find to navigate the DOM with chainable traversal steps. This works like jQuery methods — each step takes the result of the previous one as input.
| Method | Description |
|---|---|
["select", selector] | Run select_one() within the current element |
["until", selector] | Collect next siblings until one matches the selector (inclusive) |
["siblings"] | Collect all next siblings into a fragment |
Steps are applied in order. The result of each step becomes the input for the next.
Wikipedia discussion pages use DiscussionTools markup where thread content is spread across sibling elements rather than nested inside a container. The find chain navigates from the heading to the reply button, collecting everything in between:
"content": {
"selector": "div.mw-heading2",
"find": [
["until", ".ext-discussiontools-init-replylink-buttons"]
],
"transform": [
["remove", ".ext-discussiontools-init-replylink-buttons"],
["remove_after", "[data-mw-comment-sig]"]
],
"value": { "type": "html" }
}
This selects the heading container, collects all siblings until the reply button, then transforms the result by removing UI elements and stripping signatures before extracting the raw HTML.
transform)Use transform to modify a DOM fragment before extracting its value. Each step mutates a copy of the element (the original is never modified). Combine with find to first locate, then clean up content.
| Method | Description |
|---|---|
["remove", selector] | Remove all elements matching the CSS selector |
["remove_after", selector] | Remove the first match and all following siblings in its parent |
Wikipedia comments end with signature markup. Use remove_after to cut everything from the signature marker onward:
"transform": [
["remove", ".reply-button"],
["remove_after", "[data-mw-comment-sig]"]
]
html2text filterUse type: "html" to extract the raw inner HTML of an element instead of plain text. This preserves the full markup including links, code blocks, and formatting.
To convert HTML to readable plain text in email templates, use the built-in html2text Liquid filter:
{{ item.content | html2text | truncate: 1000 }}
The html2text filter converts HTML to Markdown-like plain text, preserving code blocks and link URLs. The truncate filter can be chained to limit output length.
Some websites embed structured data as JSON inside <script> tags (e.g. Next.js apps use <script id="__NEXT_DATA__">). When the HTML elements don't contain all the data you need, you can extract it from the embedded JSON instead.
Use parse: "json" combined with a query to navigate the JSON structure using JMESPath expressions.
"locations": {
"selector": "script#__NEXT_DATA__",
"value": {
"type": "text",
"parse": "json",
"query": {
"type": "list",
"path": "props.pageProps.data.items[?id == `{{id}}`].offers[]",
"variables": {
"city": { "path": "displayWorkplace" },
"url": { "path": "offerAbsoluteUri" }
}
}
}
}
How it works:
selector selects the element containing JSON (e.g. a <script> tag) — standard CSS selectortype: "text" extracts the text content — same as any other variableparse: "json" parses the text as a JSON objectquery navigates the parsed JSON and extracts variables:| Field | Required | Description |
|---|---|---|
type | yes | "list" (returns array of objects) or "single" (returns one object) |
path | no | JMESPath expression to navigate the JSON. Supports Liquid variables ({{id}}, {{name}}, etc.) rendered against the current item's data. If omitted, the root JSON object is used. |
variables | yes | Named fields to extract from each result. Each has a path (JMESPath sub-expression). |
The path supports Liquid variable interpolation, so you can match JSON entries to the current HTML item. For example, {{id}} is replaced with the item's extracted ID before the JMESPath query runs.
JMESPath is a query language for JSON. Common patterns:
| Expression | Description |
|---|---|
foo.bar.baz | Navigate nested objects |
items[0] | Array index |
items[*].name | Get name from all array entries |
items[?id == \123`]` | Filter: entries where id equals 123 |
items[?score > \50`]` | Filter: entries where score > 50 |
items[].offers[] | Flatten nested arrays |
Note: literal values in JMESPath filters use backticks (`), not quotes. See the JMESPath tutorial for full syntax.
When query.type is "list", the variable is a list of objects accessible in templates:
{% for loc in item.locations %}
* {{ loc.city }}: {{ loc.url }}
{% endfor %}
When query.type is "single", the variable is a flat object:
{{ item.metadata.author }} - {{ item.metadata.date }}
JSON can also appear in HTML attributes. Use type: "attribute" with parse: "json":
"config": {
"selector": "[data-config]",
"value": {
"type": "attribute",
"name": "data-config",
"parse": "json",
"query": {
"type": "single",
"variables": {
"status": { "path": "status" },
"count": { "path": "meta.count" }
}
}
}
}
Pracuj.pl (a Next.js app) lists job offers with multi-location variants. The HTML card only shows the title, but the city-specific URLs are in __NEXT_DATA__:
"url_list": {
"selector": "script#__NEXT_DATA__",
"value": {
"type": "text",
"parse": "json",
"query": {
"type": "list",
"path": "props.pageProps.dehydratedState.queries[0].state.data.groupedOffers[?offers[0].partitionId == `{{id}}`].offers[]",
"variables": {
"city": { "path": "displayWorkplace" },
"url": { "path": "offerAbsoluteUri" }
}
}
}
}
The {{id}} in the path is the item's ID extracted from the HTML (data-test-offerid attribute). JMESPath filters the groupedOffers array to find the matching entry, then flattens its offers[] sub-array. Each offer's displayWorkplace and offerAbsoluteUri are extracted as city and url.
The id field in the query spec controls how the scraper identifies items it has already seen.
"id": {
"source": "url",
"regex": ",(\\d+)/$"
}
Takes the url variable value and extracts the ID using a regex. The source can reference either a variable name (from variables) or a param name (from input/params). When using input, params are merged into items before ID extraction, so "source": "symbol" works if symbol is a param.
"id": {
"type": "attribute",
"name": "id"
}
Reads the id attribute directly from the matched element (e.g. <tr id="47415919">).
If no id spec is provided, the url variable is used as the identity. If there's no url either, a hash of all variables is used.
Sometimes the same listing appears multiple times with different IDs (e.g. a job offer posted under multiple categories gets a different URL slug for each). Use dedupe on the rule to collapse these based on extracted field values:
{
"ref": "job-site",
"name": "jobs",
"dedupe": ["title", "company"],
"subject": "{{count}} new offer(s)",
"template": "./templates/jobs",
"email": "you@example.com"
}
Items with identical values for all listed fields are collapsed — only the first occurrence is kept. This runs after ID-based deduplication and before state comparison.
The filter field excludes items based on CSS class:
"filter": {
"selector": ".job__header-details--date",
"exclude_class": "job__header-details--closed"
}
This finds .job__header-details--date within each item and skips the item if it has the class job__header-details--closed. Items where the filter selector doesn't match any element are also excluded.
The expect field on a query spec lists CSS selectors that must exist on the page. If any are missing, the scraper sends an error email about HTML structure changes instead of silently producing empty results.
"query": {
"expect": [".text-center img[alt='Linux']", ".pagination"],
"selector": "...",
...
}
This is checked on the first page only. Useful for detecting when a website redesigns and your selectors break.
The reject field is the inverse of expect — it lists CSS selectors that indicate the page has no real results. If any selector matches, the page returns 0 items. This is useful for sites that show recommended or unrelated content when there are no actual matches for the search query.
"query": {
"reject": ["nfj-no-offers-found-header"],
"selector": "...",
...
}
For example, nofluffjobs.com shows a "Brak wyników wyszukiwania" message and recommended jobs when a language has no remote offers. The reject selector detects the no-results element and prevents those recommendations from being treated as real results.
The input field allows a single rule to scrape multiple pages with different parameters and combine the results into one email. This is useful for monitoring multiple items on the same website (e.g. multiple stock symbols).
input can be a single object or an array:
{
"ref": "bankier",
"name": "akcje",
"subject": "[bankier.pl] Zmiany Akcji",
"template": "./templates/bankier",
"email": "you@example.com",
"input": [
{ "params": { "symbol": "BIOMAXIMA" }, "validator": { "test": "{{price}} > 10" } },
{ "params": { "symbol": "AGORA" }, "validator": { "test": "{{price}} > 9.5" } },
{ "params": { "symbol": "ASSECOPOL" } },
{ "params": { "symbol": "POLTREG" } }
]
}
Each entry fetches the URL with its own params. If input is omitted, the rule's params field is used directly (backward compatible).
Params from each input entry are merged into the extracted items, so they're available in templates (e.g. {{symbol}}).
eachWhen multiple inputs share the same structure and only differ by one parameter, use each to avoid repeating the same object:
"input": {
"each": { "var": "subreddit", "values": ["Python", "JavaScript", "scheme"] },
"params": { "feed_url": "https://www.reddit.com/r/{{subreddit}}/new.rss" },
"validator": { "@id": "hiring-posts" }
}
This expands into three input entries, one per value, each with its own params where {{subreddit}} is replaced. The validator (or track) is shared across all entries.
The each.values array can also contain objects, accessed via dot notation:
"input": {
"each": {
"var": "data",
"values": [
{ "category": "electronics", "type": "phones" },
{ "category": "computers", "type": "laptops" }
]
},
"params": { "url": "https://example.com/{{data.category}}/type/{{data.type}}" }
}
flatten)By default, when a rule has multiple input entries, all fetched items are merged into a single flat list. Set "flatten": false on the rule to keep items grouped by input entry — items becomes a nested list (list of lists), one group per input.
{
"ref": "wiki",
"name": "wiki-monitoring",
"flatten": false,
"input": [
{ "params": { "page": "SEO" } },
{ "params": { "page": "UKEN" } }
],
...
}
In the template, iterate over groups and items within each group:
{% for group in items %}
{% assign first = group | first %}
============================================================
{{ first._input.page }}
============================================================
{% for item in group %}
{{ item.title }}
{% endfor %}
{{ first._search_url }}
{% endfor %}
Each item in grouped mode gets additional metadata:
| Variable | Description |
|---|---|
{{ item._search_url }} | The rendered URL for that item's input entry |
{{ item._input }} | The params object for that item's input entry (e.g. {{ item._input.page }}) |
{{ item.index }} | Global 1-based index spanning all groups |
{{ count }} is the total number of items across all groups. {{ search_url }} is the URL of the first input entry.
When flatten is true (default) or there is only one input entry, the template works as usual with a flat items list.
Empty groups are automatically filtered out — if an input produces zero items, it won't appear in the template.
ref per input)To combine results from different definitions into a single email, give each input entry its own ref (overriding the rule's default) plus an optional friendly label. Each item gets _label for use in the template.
{
"name": "python-jobs",
"schedule": "0 9 * * *",
"subject": "[Jobs] {{count}} new Python offer(s)",
"template": "./templates/jobs-aggregated",
"email": "you@example.com",
"flatten": false,
"dedupe": ["title", "company"],
"input": [
{
"ref": "justjoin",
"label": "justjoin.it",
"params": {"keyword": "Python"},
"validator": {"@id": "job-board-python"}
},
{
"ref": "pracuj",
"label": "pracuj.pl",
"params": {"url": "https://..."}
},
{
"ref": "nofluffjobs",
"label": "nofluffjobs.com",
"params": {"language": "Python"},
"validator": {"@id": "job-board-python"}
}
]
}
Template using _label:
{% for group in items %}
=== {{ group[0]._label }} ({{ group.size }}) ===
{% for item in group %}
{{ forloop.index }}. {{ item.title }} — {{ item.company }}
{% endfor %}
{% endfor %}
When inputs use different refs, IDs are automatically namespaced as <ref>:<id> to prevent state collisions. dedupe still applies across all sources, so the same listing appearing on multiple sites is collapsed.
The rule's top-level ref becomes optional in this mode — either provide it as a fallback default, or omit it and require every input to specify its own.
Each input entry can have a validator object that filters extracted items. The validator supports two condition types. If both are present, both must pass (AND logic).
test -- ExpressionA general-purpose expression evaluated by expression-py. Supports arithmetic, comparisons, regex matching with =~, boolean operators, array operations, and Liquid variable placeholders. Item variables are available directly by name (no {{ }} needed); use Liquid placeholders only when you need a Liquid filter or custom command.
"validator": {
"test": "price > 9.5"
}
Supported operations:
| Operator | Example |
|---|---|
| Comparison | price > 10, change_pct <= -5 |
| AND / OR | (price > 80) && (change_pct < 0), (price < 5) || (price > 100) |
| Arithmetic | price * quantity > 1000 |
| Regex match | title =~ /^Ask HN/, title =~ /wikipedia/i |
| Capture groups | After title =~ /Senior (.+)/, $1 holds the first group |
| Array membership | "Python" in skills, skills & ["AI", "ML"] |
| Array equality | skills & ["Angular", "Java"] == [] (none of these match) |
| Null check | salary_from != null |
Empty arrays are falsy, so skills & ["AI", "ML"] directly evaluates to true/false: true if skills contains "AI" or "ML", false otherwise. See expression-py docs for the full operator list and precedence.
You can still use {{ }} placeholders when you need Liquid pre-processing (e.g. filters or custom commands):
{ "test": "{{ date | date: \"%Y%m%d\" }} == {{ \"now\" | date: \"%Y%m%d\" }}" }
{ "test": "{% fresh date 604800 %}" }
match -- List membership matchChecks whether a variable's value is in (or not in) a list of strings. For regex matching, use test with the =~ operator instead.
"validator": {
"match": {
"var": "skills",
"exclude": ["Angular", "C#", ".NET", "Java"]
}
}
Match condition fields:
| Field | Required | Description |
|---|---|---|
var | one of var or value | Direct variable name — returns the raw value, preserving lists from collect: true |
value | one of var or value | Liquid template string rendered against item variables (always produces a string) |
include | one of include or exclude | Array of strings — passes if any string is found (see below) |
exclude | one of include or exclude | Array of strings — passes if none are found (see below) |
strict | no | When true, include/exclude use exact string equality instead of substring match. Only affects string values — list values always use exact element matching. Default false. |
When the variable is a list (from collect: true), each element is compared as an exact match — "Java" matches the skill "Java" but not "JavaScript". For plain string values, substring matching is used by default; set strict: true for exact equality.
match can also be an array of match objects (AND logic — all must pass):
"validator": {
"match": [
{ "var": "skills", "include": ["Python"] },
{ "var": "skills", "exclude": ["Angular", "C#"] }
]
}
Both conditions must pass (AND logic within a single object):
"validator": {
"test": "price > 80 && company =~ /Asseco/",
"match": {
"var": "skills",
"exclude": ["Java", "C#"]
}
}
The validator can also be an array. The item is included if any validator in the array passes. This is useful for defining price thresholds or notification steps:
"validator": [
{ "test": "{{price}} > 8" },
{ "test": "{{price}} > 9" },
{ "test": "{{price}} > 9.5" }
]
Each entry in the array is a full validator object that can use test, match, or both.
require)In a validator array, set "require": true to make a validator mandatory. Required validators must ALL pass (AND logic), while the remaining validators use OR logic (at least one must pass). If only required validators exist, the OR check is skipped.
This is useful for combining a baseline filter with threshold alerts:
"validator": [
{ "require": true, "test": "{{score_num}} > 50" },
{ "test": "{{price}} > 75000" },
{ "test": "{{price}} > 80000" },
{ "test": "{{price}} > 100000" }
]
The require validator acts as a gate — items must pass it before the OR thresholds are even considered.
@id)Define shared validators in defs.validators and reference them by name using {"@id": "name"}. This eliminates duplication when multiple rules use the same filter:
"defs": {
"validators": {
"job-board": {
"require": true,
"match": [
{"var": "title", "exclude": ["Angular", "C#", ".NET"]},
{"var": "skills", "exclude": ["Angular", "C#", ".NET", "Java"]}
]
}
}
}
Then reference it in rules:
"input": {
"validator": {"@id": "job-board"}
}
@id references work anywhere a validator is expected — as a standalone validator, or as an element in a validator array:
"validator": [
{"@id": "job-board"},
{ "require": true, "test": "!(salary =~ /Undisclosed/)" }
]
Commands are reusable Liquid tags defined in defs.commands. Each command becomes a custom {% tag %} that can be used in validator test and match expressions, replacing verbose Liquid expressions with short, readable tags.
Commands are defined in the commands key under defs:
"defs": {
"commands": {
"fresh": {
"args": ["field", "seconds"],
"template": "{{ field | date: \"%s\" }} > {{ \"now\" | date: \"%s\" | minus: seconds }}"
},
"today": {
"args": ["field"],
"template": "{{ field | date: \"%Y%m%d\" }} == {{ \"now\" | date: \"%Y%m%d\" }}"
}
},
...
}
| Field | Required | Description |
|---|---|---|
args | no | Ordered list of argument names. Values are passed positionally when the tag is used. |
template | yes | Liquid template string rendered with bound arguments. Argument names are available as variables. |
Use commands as {% name arg1 arg2 %} in any validator test or match expression:
"validator": {
"test": "{% fresh date 604800 %}"
}
This is equivalent to writing the full Liquid expression:
"test": "{{ date | date: \"%s\" }} > {{ \"now\" | date: \"%s\" | minus: 604800 }}"
Arguments are matched positionally to the args list in the command definition. Word arguments (like date) are resolved as variables from the item context. Numeric arguments (like 604800) are passed as literal values.
The skeleton config includes two commands:
{% fresh <field> <seconds> %} — checks whether a date field is newer than a given number of seconds. Useful for filtering stale items from feeds that return non-deterministic results:
"input": {
"validator": {
"test": "{% fresh date 604800 %}"
}
}
This filters out any items where the date field is older than 7 days (604800 seconds).
{% today <field> %} — checks whether a date field matches today's date:
"input": {
"validator": {
"require": true,
"test": "{% today date %}"
}
}
Custom Liquid filters defined in defs.filters. Each key becomes a filter usable as {{ value | name }} in templates. Filters are defined using standard Liquid filter expression syntax — the input value is piped through the expression chain.
"defs": {
"filters": {
"clean": "replace_regex: '\\s+', ' ' | strip"
}
}
The expression uses standard Liquid pipe syntax. Built-in Liquid filters (strip, downcase, replace, etc.) and the additional replace_regex filter are available:
| Filter | Description |
|---|---|
replace_regex: pattern, replacement | Regex substitution (supports backreferences \1, \2, etc.) |
html2text | Convert HTML to plain text, preserving code blocks and link URLs |
Filters can be chained with | — the output of one becomes the input of the next. Custom filters can also reference other custom filters defined earlier.
Use filters with the standard Liquid pipe syntax in any template:
{{ item.snippet | clean }}
The clean filter above collapses all whitespace (newlines, tabs, spaces) into a single space and trims leading/trailing whitespace.
Two pagination types are supported:
next_link -- Follow a "next" linkFor sites with a single "More" or "Next" link (e.g. Hacker News):
"pagination": {
"type": "next_link",
"selector": "a.morelink",
"base_url": "https://news.ycombinator.com/",
"max_pages": 2
}
numbered -- Follow numbered page buttonsFor sites with numbered pagination (e.g. useme.com):
"pagination": {
"type": "numbered",
"selector": ".pagination .pagination__page",
"active_class": "pagination__page--active",
"base_url": "https://useme.com/pl/jobs/",
"max_pages": 5
}
Finds the active page button and follows the link of the next one.
| Field | Required | Description |
|---|---|---|
max_pages | no | Maximum number of pages to fetch (default: 1) |
base_url | no | Base URL for resolving relative href values |
Each rule can have a schedule field with a standard cron expression or an array of expressions (any match triggers the rule). The script is designed to be invoked frequently (e.g. every 5 minutes via system cron), and it decides internally which rules are due based on their schedule.
The schedule uses croniter to parse standard 5-field cron expressions:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
│ │ │ │ │
* * * * *
| Expression | Meaning |
|---|---|
0 8 * * * | Daily at 8:00 |
0 */6 * * * | Every 6 hours (0:00, 6:00, 12:00, 18:00) |
0 9 * * 1 | Every Monday at 9:00 |
*/30 * * * * | Every 30 minutes |
0 8,20 * * * | Twice daily at 8:00 and 20:00 |
When a single cron expression can't cover your needs, use an array. The rule runs if any expression matches:
"schedule": ["0,30 9 * * *", "0 16 * * *"]
This runs at 9:00, 9:30, and 16:00 — something not expressible in a single 5-field cron string.
The script is designed to be invoked periodically by system cron (e.g. every 5 minutes or every hour). On each invocation:
croniter.match~/.mutimon/data/.lastrun_<rule_name> to prevent duplicate runs if the script is triggered again within the same minute--force to bypass all schedulesTemplates use Liquid syntax via python-liquid. The following variables are available:
| Variable | Description |
|---|---|
{{ count }} | Number of new items |
{{ now }} | Current date and time |
{{ search_url }} | The rendered URL from the definition |
{% for item in items %} | Loop over new items |
{{ item.index }} | 1-based position within the items list |
Any rule params | e.g. {{ query }} |
| Any extracted variable | e.g. {{ item.title }}, {{ item.url }}, {{ item.score }} |
Liquid supports conditionals, filters, and logic — see the Liquid docs.
Hacker News - New Stories
Checked at: {{ now }}
Number of new stories: {{ count }}
============================================================
{% for item in items %}
{{ item.rank }} {{ item.title }}
Score: {{ item.score }} point{% if item.score != 1 %}s{% endif %} | {{ item.age }}
URL: {{ item.url }}
HN: {{ item.comments_url }}
{% endfor %}
============================================================
The subject field in a rule is also a Liquid template with access to the same variables.
parse: "money", the page language is detected from <html lang> or the Content-Language header, and used for locale-aware currency parsing via babel~/.mutimon/data/<rule_name>_valid flag (or _state index for track mode), so threshold crossings are detected on subsequent runsWhen a rule has validators, the scraper tracks whether each item passed or failed on the previous run. This enables re-notifications when a value crosses a threshold boundary:
>= 75000 passes → notify, save _valid: true_valid: false_valid was false → notify again_valid was true → no notificationThis works for both upward thresholds (>=) and downward thresholds (<=). The state file stores all fetched items (not just those passing the validator) with a _valid boolean.
track)For more granular threshold monitoring, use track instead of validator on an input entry. While validator stores a single pass/fail boolean, track implements a state machine that tracks which threshold an item is in and notifies on every state transition.
track and validator are mutually exclusive on the same input entry.
"input": {
"params": { "symbol": "ASSECOPOL" },
"track": {
"value": "{{price}}",
"states": [
{ "test": "{{price}} > 200", "name": "above 200 zł" },
{ "test": "{{price}} > 190", "name": "above 190 zł" },
{ "test": "{{price}} > 180", "name": "above 180 zł" },
{ "test": "{{price}} <= 180", "silent": true }
]
}
}
| Field | Required | Description |
|---|---|---|
value | no | Liquid expression to evaluate and save as _value for templates. |
states | yes | Array of state definitions, evaluated top-down. First matching state wins. |
states[].test | yes | expression-py expression with optional Liquid variables. |
states[].name | no | Human-friendly label, available as {{ item._state_name }} in templates. Defaults to the test expression. |
states[].silent | no | If true, transitioning to this state saves state but does not trigger a notification. Default false. |
States are evaluated top-down — the first matching test expression determines the item's current state (by index). On each run:
silentsilent"track": {
"value": "{{price}}",
"states": [
{ "test": "{{price}} > 200", "name": "above 200 zł" },
{ "test": "{{price}} > 190", "name": "above 190 zł" },
{ "test": "{{price}} > 180", "name": "above 180 zł" },
{ "test": "{{price}} <= 180", "silent": true }
]
}
| Run | Price | State | Previous | Notify? |
|---|---|---|---|---|
| 1 | 185 | above 180 zł (2) | — | Yes (new) |
| 2 | 188 | above 180 zł (2) | above 180 zł (2) | No (same state) |
| 3 | 195 | above 190 zł (1) | above 180 zł (2) | Yes (crossed 190) |
| 4 | 170 | silent (3) | above 190 zł (1) | No (silent) |
| 5 | 185 | above 180 zł (2) | silent (3) | Yes (came back) |
| 6 | 195 | above 190 zł (1) | above 180 zł (2) | Yes (crossed 190 again) |
In track mode, the following variables are available in templates:
| Variable | Description |
|---|---|
{{ item._state_name }} | Name (or test expression) of the current state |
{{ item._prev_state_name }} | Name of the previous state (or empty for new items) |
{{ item._value }} | Rendered value from track.value (e.g. the current price) |
track vs validatorvalidator — binary filter: include/exclude items. Good for "notify me about new Hacker News posts with score > 100" or "exclude job offers with Angular".track — state machine: notify on every threshold crossing. Good for "notify me each time ASSECOPOL crosses above 190 zł, and again when it crosses above 200 zł".Definitions without a query section act as health checks. Instead of parsing HTML/XML/JSON, Mutimon makes an HTTP request and returns a single item with response metadata. This is useful for monitoring website uptime.
When query is omitted, the returned item has an http object with the following fields:
| Variable | Type | Description |
|---|---|---|
{{ http.code }} | int | HTTP status code (0 for connection errors) |
{{ http.method }} | string | Request method used (GET, HEAD, POST) |
{{ http.body }} | string | Response body |
{{ http.headers }} | dict | Response headers (all keys lowercase) |
{{ http.response_time }} | float | Response time in seconds |
{{ http.error }} | string/null | Error message on connection failure, null on success |
The item's id is set to the requested URL automatically.
Definition (reusable for any URL):
{
"defs": {
"health": {
"url": "{{ url }}"
}
}
}
Rule with track for up/down state notifications:
{
"name": "my-sites",
"ref": "health",
"schedule": "*/15 * * * *",
"input": [
{ "params": { "url": "https://example.com/" } },
{ "params": { "url": "https://api.example.com/" } }
],
"track": [
{ "name": "down", "test": "({{ http.code }} >= 400) | ({{ http.code }} == 0)" },
{ "name": "up", "test": "{{ http.code }} >= 200", "silent": true }
],
"subject": "Site down: example.com",
"template": "./templates/health",
"email": "you@example.com"
}
With "silent": true on the "up" state, you only get notified when a site goes down. Remove "silent" to also get notified when it recovers.
Example template (~/.mutimon/templates/health):
Health check at {{ now }}
============================================================
{% for item in items %}
{{ item.url }}
Status: {{ item._state_name }} (HTTP {{ http.code }})
Response time: {{ http.response_time }}s{% if http.error %}
Error: {{ http.error }}{% endif %}
{% endfor %}
Use match with a value Liquid template to check response headers:
{
"validator": {
"match": {
"value": "{{ http.headers['content-type'] }}",
"regex": "^application/json"
}
}
}
Header names are always lowercase in the http.headers dict, regardless of how the server returns them.
Definitions can include a validator and/or track that act as defaults for all rules referencing that definition. This avoids repeating the same filter across multiple rules or input entries.
A definition-level validator is AND-merged with input-level validators. The def validator becomes a require: true gate — items must pass it before the input-level validators are considered.
"defs": {
"atom": {
"url": "{{feed_url}}",
"format": "xml",
"query": { ... },
"validator": { "test": "{% fresh date 604800 %}" }
}
}
Any rule using the atom def automatically filters out items older than 7 days. If a rule adds its own validator (e.g. a title regex), both must pass — freshness AND the title match.
When an input entry has its own track, the def-level validator is ignored (since track and validator are mutually exclusive).
A definition-level track provides a default state machine for all rules. Input-level track overrides the def-level track entirely (no merging).
"defs": {
"health": {
"url": "{{ url }}",
"track": {
"states": [
{ "name": "down", "test": "({{ http.code }} >= 400) | ({{ http.code }} == 0)" },
{ "name": "up", "test": "{{ http.code }} >= 200", "silent": true }
]
}
}
}
Rules using the health def get the default up/down tracking. An input entry can override with a custom track (e.g. a CORS proxy that returns 400 normally):
"input": [
{ "params": { "url": "https://example.com/" } },
{
"params": { "url": "https://proxy.example.com/" },
"track": {
"states": [
{ "name": "down", "test": "({{ http.code }} >= 500) | ({{ http.code }} == 0)" },
{ "name": "up", "test": "{{ http.code }} > 0", "silent": true }
]
}
}
]
The first entry uses the def's default track. The second overrides it with a custom threshold.
When an input entry has its own validator, the def-level track is ignored.
The scraper sends error emails for four types of failures. The error email function (send_error_email) uses only Python's standard library (no third-party deps), so it works even when the error is caused by a missing dependency.
| Error | Email subject | Behavior |
|---|---|---|
Missing dependency (e.g. import liquid fails) | [mutimon] Missing dependency | Sends traceback, exits |
| Invalid config (schema validation fails) | [mutimon] Invalid configuration | Sends all validation errors, exits |
HTML structure change (expect selectors missing) | [mutimon] HTML structure changed for '<rule>' | Sends missing selectors, skips that input, continues other rules |
Fatal runtime crash (unhandled exception in main()) | [mutimon] Fatal error | Sends full traceback |
Error emails are sent to all unique recipient addresses found across all rules in the config.
The skeleton/ directory contains ready-to-use examples that are copied to ~/.mutimon/ on first run.
Monitors the Hacker News front page for new stories. Uses pagination to fetch 2 pages (60 stories), sibling element extraction for scores, and data-test attribute-based IDs.
Files: skeleton/config.json (hackernews def + rule), skeleton/templates/hackernews
Monitors Bitcoin price on CoinMarketCap with threshold-based alerts. Demonstrates:
$70,528.40 is correctly parsed as 70528.40 using parse: "money" (US English format detected from <html lang="en">)expect field checks that [data-test='text-cdp-price-display'] exists on the pageFiles: skeleton/config.json (coinmarketcap def + rule), skeleton/templates/coinmarketcap
The bitcoin rule uses two input entries — one for upward thresholds (>=), one for downward thresholds (<=):
"input": [
{
"params": { "coin": "bitcoin" },
"validator": [
{ "test": "{{price}} >= 75000" },
{ "test": "{{price}} >= 80000" },
{ "test": "{{price}} >= 100000" }
]
},
{
"params": { "coin": "bitcoin" },
"validator": [
{ "test": "{{price}} <= 60000" },
{ "test": "{{price}} <= 50000" }
]
}
]
Tip: For more granular notifications (e.g. notify each time the price crosses a specific level, not just when it re-enters a "passing" state), use
trackinstead ofvalidator.
Monitors stock prices with per-threshold notifications using track. Unlike the Bitcoin example which uses validator (binary pass/fail), track notifies on every state transition — e.g. when a stock crosses above 190 zł, then again when it crosses 200 zł.
"input": [
{
"params": { "symbol": "ASSECOPOL" },
"track": {
"value": "{{price}}",
"states": [
{ "test": "{{price}} > 200", "name": "above 200 zł" },
{ "test": "{{price}} > 190", "name": "above 190 zł" },
{ "test": "{{price}} > 180", "name": "above 180 zł" },
{ "test": "{{price}} <= 180", "silent": true }
]
}
}
]
The silent state at the bottom acts as a "reset" — when the price drops below 180, no notification is sent, but the state is saved. When the price rises back above 180, it's detected as a state change and triggers a new notification.
Monitors a Reddit subreddit via its Atom feed (Reddit serves .rss URLs as Atom XML). Demonstrates:
format: "xml" switches from HTML to XML parsing, so CSS selectors target XML elements (entry, title, link) instead of HTMLsubreddit param lets the same definition monitor any subredditentry for items, link[href] for URLs (Atom uses <link href="..."/> instead of <link>text</link>)Files: skeleton/config.json (reddit-atom def + rule), skeleton/templates/reddit
"reddit-atom": {
"params": ["subreddit"],
"format": "xml",
"userAgent": "Liferea/1.15.6 (Linux; https://lzone.de/liferea/) AppleWebKit (KHTML, like Gecko)",
"url": "https://www.reddit.com/r/{{subreddit}}.rss",
"query": {
"type": "list",
"selector": "entry",
"id": { "source": "entry_id" },
"variables": {
"title": { "selector": "title", "value": { "type": "text" } },
"url": { "selector": "link", "value": { "type": "attribute", "name": "href" } },
"entry_id": { "selector": "id", "value": { "type": "text" } },
"date": { "selector": "updated", "value": { "type": "text" }, "default": "" },
"author": { "selector": "author name", "value": { "type": "text" }, "default": "" }
}
}
}
eachMonitors multiple subreddits for posts about hiring Python or JavaScript developers. Demonstrates:
each expansion: a single input object expands into multiple fetches, one per subreddit — no need to repeat the same params/validator for eachdefs.validators defines a named validator referenced by @id across all expanded entriesDefinition: uses the same reddit-atom def from the previous example.
Reusable validator:
"validators": {
"reddit-hiring": [
{ "test": "{% fresh date 604800 %}", "require": true },
{ "test": "title =~ /\\b(hiring|hire|looking for)\\b.*(Python|JavaScript)/i" }
]
}
Rule:
{
"ref": "reddit-atom",
"name": "reddit-hiring-dev",
"schedule": "0 */4 * * *",
"subject": "[Reddit] {{count}} new hiring post(s)",
"template": "./templates/reddit",
"email": "you@example.com",
"input": {
"each": { "var": "subreddit", "values": ["Python", "JavaScript"] },
"params": { "subreddit": "{{subreddit}}" },
"validator": { "@id": "reddit-hiring" }
}
}
Files: skeleton/config.json (reddit-atom def + rule), skeleton/templates/reddit
Mutimon ships with an AI instruction file that teaches any AI assistant how to add websites. Get its path with:
mon --ai-guide
Use it with Claude Code in batch mode:
claude -p "$(mon --ai-guide) Add https://github.com/trending to mutimon. Extract repo name, description, URL, language, and stars. Email me daily at 8am at user@example.com."
Or with any AI assistant — just paste the output of mon --ai-guide as context along with your request.
Add a rule to monitor Hacker News (https://news.ycombinator.com) for new stories. Extract the title, URL, score, and age. Send me an email every 6 hours at user@example.com with the new stories. Read the AI guide with
mon --ai-guidefor config format reference.
Add Bitcoin price monitoring using https://coinmarketcap.com/currencies/bitcoin/. Notify me when the price crosses above $75,000 or drops below $60,000. Check every 4 hours. Send alerts to user@example.com. Read the AI guide with
mon --ai-guidefor config format reference.
Monitor ASSECOPOL stock on https://www.bankier.pl/inwestowanie/profile/quote.html?symbol=ASSECOPOL. Use
track(notvalidator) to notify me each time the price crosses above 180, 190, or 200 zł. Add a silent state for below 180 so I only get notified when it rises back above thresholds. Check twice daily during market hours. Read the AI guide withmon --ai-guidefor config format reference.
Monitor https://soloterm.com/download for Linux support. The page currently shows "Coming soon" next to Linux. Notify me when that label disappears (use the match validator with exist: false). Also add an expect check so I get an error email if the page structure changes. Read the AI guide with
mon --ai-guidefor config format reference.
Add a rule to monitor the r/scheme subreddit via its RSS feed at https://www.reddit.com/r/scheme.rss. Reddit serves Atom XML, so use format "xml" and a Liferea User-Agent. Extract the title, URL, author, and date. Check every 6 hours and email me at user@example.com. Read the AI guide with
mon --ai-guidefor config format reference.
Add a rule to monitor Hacker News for "Ask HN" posts only. Use the existing hackernews definition with a match validator that filters titles starting with "Ask HN". Read the AI guide with
mon --ai-guidefor config format reference.
Add a rule to monitor the r/scheme subreddit via its Atom feed. Use the
{% fresh date 604800 %}command to filter out posts older than 7 days, since Reddit's feed sometimes returns stale posts. Read the AI guide withmon --ai-guidefor config format reference.
eachMonitor r/Python and r/JavaScript subreddits for posts about hiring developers. Use the
eachinput expansion to avoid duplicating the input entry for each subreddit. Filter titles containing "hiring", "hire", or "looking for" combined with "Python" or "JavaScript". Use a reusable validator indefs.validators. Check every 4 hours. Read the AI guide withmon --ai-guidefor config format reference.
Add a rule to monitor job offers on https://it.pracuj.pl. The site is a Next.js app — some data (like city-specific URLs for multi-location offers) is only in the
<script id="__NEXT_DATA__">JSON, not in the HTML. Useparse: "json"with a JMESPath query to extract city and URL from the embedded JSON. Read the AI guide withmon --ai-guidefor config format reference.
Add a health check rule to monitor https://example.com and https://api.example.com. Use a definition without
queryto get HTTP response metadata. Usetrackstates to notify when a site goes down (status >= 400 or connection error) and stay silent when it's up. Check every 15 minutes. Read the AI guide withmon --ai-guidefor config format reference.
Add a rule to monitor a Wikipedia discussion page for new threads. The page uses MediaWiki DiscussionTools where thread content is spread across sibling elements, not nested in a container. Use
findwithuntilto collect siblings between headings and reply buttons,transformto strip signatures and UI elements, andtype: "html"with thehtml2textLiquid filter to convert the content to plain text in the template. Read the AI guide withmon --ai-guidefor config format reference.
The logo was created as a combination of clipart from OpenClipart:
It also uses Lovelo font.
Mutimon is a concise Latin portmanteau formed from mutare (“to change”) + monere (“to warn / monitor”).
Copyright (C) 2026 Jakub T. Jankiewicz
Released under GPL-3.0 license
100 commits
Python
99.8%