TomLabs Apps
← All applications

DřevoBot

Firewood prices from Czech shops in one table: every offer converted to a solid cubic meter, dry and fresh kept apart, with the price history behind each one.

web blazor mudblazor firewood prices crawler map Public Homepage

DrevoBot

An aggregator of firewood prices in the Czech Republic. It walks sellers' websites, pulls the offer prices out of them, converts them to a common unit and shows them in a table and in trend charts. The user interface is Czech, because the buyers are.

How it works

  1. A vendor (one website) is created in the admin UI, together with a recipe – the procedure for pulling individual offers out of its listing page.
  2. A background crawler runs the recipes whose interval has elapsed (24 hours by default).
  3. The species, unit, log length and how dry the wood is are derived from the offer name, and the price is converted to a solid cubic meter so offers can be compared.
  4. A new point in the price history is written when the price or availability changes, and at most once a day otherwise.

Unit conversion

Without the conversion a loose cubic meter cannot be compared to a stacked one, let alone to a tonne. The usual coefficients are used:

Unit Solid cubic meters Note
solid cubic meter (plnometr, plm) 1.00 one cubic meter of wood
stacked cubic meter (prostorový metr rovnaný, prm) 0.65 stacked logs
loose cubic meter (prostorový metr sypaný, prms) 0.44 poured in loosely
tonne 1.43 hardwood / 2.08 softwood by the density of dry wood
pallet, bag, bundle, piece – no settled volume, not converted

Briquettes and pellets are never converted – for pressed fuel it would make no sense.

Dry and fresh wood

Fresh wood is cheaper per cubic meter than dry wood, but it has to season for about a year before it burns well. The two are therefore treated as separate markets and their prices are never averaged together: the front page carries a median for each, the price-over-time chart draws one series per moisture group, and the comparisons by species, region and vendor always run inside a single group. Kiln dried wood counts as dry. How dry an offer is comes from its name; what the name does not say falls back to the recipe default, and an offer that says nothing anywhere is taken to be fresh. Silence usually means freshly felled wood, and guessing dry would slip a cheap offer into the dry median.

Hard and soft wood

Both medians are broken down once more, into hard and soft wood, and the front page shows all four numbers. Beech, oak, hornbeam, ash, black locust, maple, elm and fruit wood count as hard; spruce, pine, fir and larch as soft, and so do birch, alder, poplar and willow, which the Czech trade sells as soft wood whatever a botanist would say - the price follows the trade. Briquettes and pellets belong to neither.

The species picker on the overview lists the concrete species under those two families and can filter by a whole family at once, and the comparison by species lines the bars up the same way.

Running it

dotnet run --project DrevoBot.Web --launch-profile http

The application runs at http://localhost:5205. The SQLite database (drevobot.db) is created on the first start.

In the development environment the demo data seeding is on (Demo:Seed), so the table and the charts have something to show. The demo vendors carry "(DEMO)" in their name and their recipes are disabled.

Pages

Address What is on it
/ Offer overview with filters and sorting
/grafy Price development over time, a map of the regions, comparison of species, regions and vendors
/admin Vendors and their recipes
/admin/recept/{id} Recipe editor: selectors, parsing test, AI suggestion, manual run
/admin/hledani Vendor discovery: the model searches the web, the app verifies the finds
/admin/behy Crawler run history
/admin/logy Application log, searchable by level, source and text
/login Sign-in of the administrator; everything under /admin needs it

Writing a recipe

A recipe has three strategies:

  • Auto – structured data first (JSON-LD Product / microdata schema.org/Product), and when nothing comes out of them, the CSS selectors are used. This is the default and for Shoptet, WooCommerce and PrestaShop it often works with no further setup.
  • Structured data only
  • Selectors only – ItemSelector marks the repeating block of one offer, every other selector is evaluated inside it.

Where the card names the species away from the name - „Štípané dřevo měkké" with „smrk a borovice" underneath - the Dřevina selector points at that text. It is read together with the name. One named species is that species; two or more make a mixture - all hard gives the hardwood mix, all soft the softwood mix, and a card naming both goes by the family word it uses ("listnaté", "měkké"). "Tvrdé dřevo (dub, buk, jasan)" is therefore the hardwood mix, not beech, and "smrk a borovice" the softwood mix rather than spruce. The same goes for the Jednotka selector when the unit sits away from the price.

Sites that load their prices with JavaScript need the Browser fetch mode. It uses Playwright, whose browsers have to be installed once:

pwsh DrevoBot.Web/bin/Debug/net10.0/playwright.ps1 install chromium

Debugging a recipe

"Otestovat parsování" downloads the first page and, besides the offers, prints what happened at every step: how big the downloaded HTML was, whether structured data were there, how many blocks the item selector matched, how many blocks each of the other selectors hit and what it read out of them, and finally the raw texts of the first blocks with the number the price parser got out of them. A selector that matches several elements inside one block is flagged - only the first one is used, so a card carrying two prices silently loses one of them.

When the item selector matches nothing, the page is scanned for repeating blocks that contain a price and the candidates are offered with a sample of their text; one click puts one into the form.

Price for more than one unit

Shops write "1 950 Kč / 1,6 prms", which is the price of 1.6 loose cubic meters. The quantity is divided out, so the offer is stored as the price of a single unit and the original text is kept in the price history. Only volume units are divided this way; a weight next to the price ("paleta 960 kg") says how heavy the goods are, not how many units are sold.

AI-suggested selectors

The "Navrhnout selektory (AI)" button downloads the HTML of the listing page, strips the scripts and styles and lets the model suggest the selectors. The suggestion is only displayed - it reaches the recipe after confirmation and nothing is saved without clicking Save.

Finding new sellers

The page /admin/hledani lets the model search the web for firewood sellers in a chosen region. Nothing it returns is taken on faith: the application downloads every candidate itself and scores how much the page looks like a firewood shop - mentions of palivové dřevo, a known unit, a species, a price. Candidates land in a queue and only a human decision turns one into a vendor, so the crawler never starts on a site nobody looked at. A rejected candidate is remembered, so the next search does not offer it again.

Sellers without a price list

Plenty of small sellers publish no prices at all and answer by phone. Such a vendor keeps a manual price list at /admin/cenik/{id} instead of a recipe: the price learned over the phone is typed in together with the day it held and a note about where it came from. From there the offer behaves like any other one - it converts to a solid cubic meter, keeps a price history and counts into the medians - only it is marked "ručně" in the overview, so a phone call is never mistaken for a published price. After ninety days the entry is flagged for a fresh call, because a year-old price from a phone call is a guess rather than data.

Automation channel (MCP and REST)

An agent fills and drives the application through the same operations the admin UI offers, without clicking through it. Two entry points sit on top of one service (DrevoBot.Web/Automation/):

  • MCP server at /mcp - Streamable HTTP, stateless, tools such as list_vendors, save_vendor, save_recipe, inspect_page, test_recipe, run_recipe, list_offers, get_market_summary, save_manual_price, ignore_offer, get_logs. .mcp.json in the repository registers the production server (drevobot, token from the environment variable DREVOBOT_AUTOMATION_TOKEN) and the local one (drevobot-local, token local-dev-token from appsettings.Development.json) for Claude Code.
  • REST mirror at /api/automation for curl and scripts: GET /status, GET|POST /vendors, PUT|DELETE /vendors/{id}, GET|POST /recipes, PUT|DELETE /recipes/{id}, POST /recipes/test (a draft), POST /recipes/{id}/test, POST /recipes/{id}/run, GET /runs, GET /page?url=&selector=, GET /offers, GET /market, GET /stats/species, GET /stats/vendors, GET /vendors/{id}/manual-prices, POST /manual-prices, DELETE /manual-prices/{id}, GET /logs. Enums travel as names (Beech, StackedCubicMeter, SouthBohemian). Bad input answers 400, a missing record 404.

Both paths require Authorization: Bearer <token> with the value of Automation:Token (AUTOMATION_TOKEN in .env on the server, AutomationToken in the pipeline variable group). Without a token the channel answers 503 to everything, so a build deployed without the secret exposes nothing. Anything reached through it is logged like any other request.

The loop for a new shop: inspect_page to look at the product cards (a selector returns only the matched elements), save_vendor, test_recipe with a draft until the offers and diagnostics look right - the diagnostics are the same as the admin "Otestovat parsování", item selector hints included - then save_recipe, run_recipe and list_offers to check what landed.

Model providers

OpenAI is the default provider, Claude is the fallback. The provider is chosen by Ai:Provider, and when only one key is present, that one is used regardless of the setting.

setx OPENAI_API_KEY "sk-..."
setx ANTHROPIC_API_KEY "sk-ant-..."

Without any key both AI features are disabled and the rest of the application is unaffected. Web search runs server-side at the provider, so no separate search API key is needed.

App store entry and analytics

The application is listed on the TomLabs app store at https://apps.tomlabs.cz: after a successful deploy the pipeline calls the store's templates/publish-web-app.yml@appstore template with the entry from appstore.json, the README, the changelog and DrevoBot.Web/wwwroot/icon.png. The store then reads the running version and status from /health by itself, which is why that endpoint has to keep answering.

Visits are measured with GA4 through tomlabs-consent.js — the shared script from TomLabs.Consent, synced with its Sync-Consent.ps1. Nothing is sent to Google before a visitor accepts, the Cookies link in the footer reopens the banner, and without Analytics:MeasurementId no banner is shown and no tag is loaded, which is how it behaves while developing. The id comes from the pipeline variable group web-analytics (GA_MEASUREMENT_ID, the shared "TomLabs web" property) and the store token from appstore-publish.

The map

The base map under it is optional: with a Mapy.com key in Mapy:ApiKey the tiles are proxied through /api/tiles, so the key never reaches the browser, and without one the regions are drawn on the page's own paper (OpenStreetMap stands in while developing). The key travels like the other secrets: MAPY_API_KEY in the pipeline variable group, MAPY_API_KEY in .env on the server.

The charts page carries a map of the fourteen regions, filled by the median price per solid cubic meter of the moisture group on display - dry and fresh never share a map. A region with a seller but no published price is hatched, one without a seller stays empty, so the map doubles as the coverage view. Leaflet draws it from wwwroot/data/kraje.geojson, the region shapes Katalog arboristů uses (ČÚZK, CC BY 4.0); there is no base map underneath, because a choropleth covers it anyway and a basemap would mean a tile key. The five colour steps are a sequential ramp whose faintest step still reads against both surfaces, and the numbers are listed under the map for anyone the colour alone does not serve.

The look

The colours, borders, radii and the monospace face come from wwwroot/tomlabs.css, a token layer that mirrors the "ops console" style of the TomLabs desktop applications. It holds nothing application-specific: the accent - ember, because the goods are firewood - is set in app.css, the way every desktop application picks one accent of its own. The look is opt-in and belongs to the tool-like applications; when a second one adopts it, the file moves to a shared package unchanged.

The layer carries two densities. data-density="comfortable", the default, is for the pages visitors read - the price overview and the charts. data-density="compact" is the operator's console and is stamped on everything under /admin. The layout writes both that attribute and data-theme onto the document, so the chart palette follows the theme toggle and not merely the operating system.

The shell follows the desktop as well: Ctrl+K opens a command palette that searches the pages, the recipes and the manual price lists, the selected row in the sidebar carries a 2px accent bar, and a status bar along the bottom says how much data there is and when it was last updated. The global shortcut is registered in wwwroot/tomlabs.js, which belongs to the shell rather than to DrevoBot.

The eight chart hues are a palette validated for colour vision deficiency, with their own steps for the dark surface. Four of them sit below 3:1 against the light surface, so a chart that uses them always carries visible labels or a table view.

Signing in

The administration (/admin and everything under it, plus the ignore button on the overview) is behind one password, Admin:Password. A sign-in sets a cookie for a month; the form posts to a plain endpoint because a Blazor circuit cannot set a cookie once it is running. The overview and the charts are public. Without a configured password nobody can sign in, so a deployment that lost the secret keeps the admin locked rather than open. The automation channel uses its own bearer token and is unaffected.

Application log

Serilog writes to the console and, through a background writer, into the database. The page /admin/logy filters by level, source and text and shows the stack trace of any exception, so reading the log does not need SSH access to the server. Records older than the retention window are deleted automatically.

Configuration

appsettings.json:

Key Meaning
ConnectionStrings:Drevo SQLite connection string
Crawler:Enabled turn it off to run recipes only by hand from the admin UI
Crawler:CheckIntervalMinutes how often it checks what is due
Crawler:DelayBetweenRecipesSeconds pause between recipes in one round
Demo:Seed fill an empty database with demo data
Ai:Provider openai or claude
Ai:OpenAi:Model model that reads HTML and suggests selectors
Ai:OpenAi:SearchModel model used for the web search when looking for sellers
Ai:MaxHtmlChars how much HTML is sent to the model (a longer page is truncated)
Logging:RetentionDays how long log records are kept in the database
Logging:MaxEntries upper bound on the number of log records
Automation:Token bearer token of the MCP server and the REST mirror; empty switches the channel off
Mapy:ApiKey Mapy.com key for the base map under the region map; empty draws the regions without one
Analytics:MeasurementId GA4 measurement id; empty means no consent banner and no tag
Admin:Password password of the administration pages; empty keeps them locked

Being polite to the sellers

The crawler introduces itself with its own User-Agent, keeps a two second pause between requests to the same site and runs the recipes in one round serially. The default interval of once a day is plenty – firewood prices move over weeks.

Layout

DrevoBot.Core/
  Domain/      entities: vendor, recipe, offer, price point, run
  Data/        EF Core context and migrations
  Scraping/    page fetching (HTTP, Playwright) and offer extractors
  Parsing/     parsers for price, unit and species, plus the conversion to a solid cubic meter
  Services/    offer normalization, run results and the vendor discovery
  Analytics/   queries for the table and the charts
  Ai/          one interface over OpenAI and Claude, plus the recipe advisor
DrevoBot.Web/  Blazor Server on MudBlazor: public pages, admin UI, background crawler and log writer

Deployment to the VPS

Production runs at https://drevobot.tomlabs.xyz from the main branch: the application in a container behind the host nginx, data in SQLite in a Docker volume.

Deployment repository:

https://tomlabscloud.visualstudio.com/DrevoBot/_git/DrevoBot

Release pipeline

azure-pipelines.yml runs on every push to main and has two stages:

  1. Build – restores packages, compiles and publishes the application, checks that the deployment files exist, that the headless-shell install in the Dockerfile matches the channel the fetcher asks for, and that no migration contains a destructive step. Finally it builds the image.
  2. DeployVps – uploads the sources to the VPS and runs deploy/deploy-vps.sh, which backs up the database, builds the image, brings the stack up, reloads nginx and waits for a healthy /health answer. A smoke test against the public domain follows, including a check that production really runs the version that was just deployed.

The variable group is called drevobot-vps-deployment. Only one item is required:

Variable Type Meaning
VPS_SSH_PRIVATE_KEY_B64 secret private SSH key for codexdeploy, base64 encoded

Optional:

Variable Meaning
OpenAiApiKey OpenAI key for the selector suggestions and the vendor search. When it is not filled in, the value in .env on the server stays as it was.
AnthropicApiKey Claude key, used when Ai:Provider is set to claude.
AutomationToken bearer token of the automation channel (/mcp, /api/automation). Without it the channel stays off.
AdminPassword password of the administration pages. Without it nobody can sign in.
MAPY_API_KEY Mapy.com key for the map tiles. Without it the map has no base layer.
GA_MEASUREMENT_ID GA4 measurement id, from the shared web-analytics group.
APPSTORE_TOKEN Upload token of the app store, from the shared appstore-publish group.

The script can also be run by hand directly on the server:

bash /opt/drevobot/deploy/deploy-vps.sh

DNS

drevobot.tomlabs.xyz needs an A record pointing at the VPS address (37.205.12.163) before the pipeline runs for the first time – Certbot issues the certificate during the first deployment and fails without a working DNS validation.

Manual first setup

sudo mkdir -p /opt/drevobot
sudo chown $USER:$USER /opt/drevobot
git clone -b main https://tomlabscloud.visualstudio.com/DrevoBot/_git/DrevoBot /opt/drevobot
cd /opt/drevobot
cp .env.example .env
nano .env

sudo docker compose build web
sudo docker compose up -d

sudo cp deploy/nginx-drevobot.conf /etc/nginx/conf.d/drevobot.tomlabs.xyz.conf
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d drevobot.tomlabs.xyz

Once the certificate is issued, do not overwrite the file in conf.d by hand any more – Certbot adds the HTTPS part to it and the deploy script therefore leaves it alone.

Verification:

sudo docker compose ps
curl http://127.0.0.1:5209/health

What lives where on the server

Path Contents
/opt/drevobot release sources, docker-compose.yml, deploy/
/opt/drevobot/.env configuration that the deployment never overwrites
/opt/drevobot/backups database backups taken before each deployment, ten are kept
volume drevobot_drevobot_data drevobot.db and the data protection keys

The database is a single SQLite file in the volume. The deploy script copies it into backups/ before every rebuild – the container is stopped for that moment, because a copy of a live database can catch a half-written transaction.

Disk usage

The runtime image takes about 1.65 GB, of which Chromium and its system libraries are 0.61 GB. The dotnet/sdk image needed for building on the server adds 1.25 GB and the build cache about 2 GB, so the first deployment costs roughly 5 GB. The database is negligible: one price point including indexes is about 150 B and is written at most once a day per offer, which is around 11 MB a year for 200 tracked offers.

Changelog

Newest version first. Each section lists what changed for the user, not the commits.

0.31.2 — 2026-10-08

Changed

  • Every row of the price overview leads to the offer. Clicking or tapping anywhere on a row (a card on the phone) opens the seller’s product page in a new tab.
  • The offer name is always a link — to the product page, or to the seller’s site when the shop has no page for that product (hand-entered prices).
  • The seller’s name links to the seller’s site.

0.31.1 — 2026-10-07

Fixed

  • „Celé kusy“ and „celé kmeny“ are counted as unsplit, as every hand entry had them — logs of two to five metres sold whole.

0.31.0 — 2026-10-07

Added

  • Price lists printed without VAT can be followed automatically. A recipe can now add the shop’s own VAT rate to every price it reads, so such a list lands on the same footing as every other price on the site instead of coming out a sixth cheaper and topping every ranking. The raw text still records what the shop wrote.
  • Price tables with merged cells are read the way they look. A price merged down several rows — one price for „buk, dub, bříza, javor, jasan“ — is one offer named after all of them, not a price for the first species and nothing for the rest, nor five copies counted five times. A table whose first row is a single cell treats it as a caption and puts it into every name, because it is often the only place that says the wood below is unsplit.

Fixed

  • A price that states its own unit is the price of one. „2 500 Kč/prm“ under a caption „rovnané na palety 1,7 prm“ was divided by 1.7. When the price text names a unit and no amount, the amount is one and nothing else is consulted.

0.30.7 — 2026-10-07

Fixed

  • „Vlhkost max. 20–25 %“ was read as fresh wood. Any word starting „vlhk“ counted as damp, including the noun that only introduces a figure, so fairly dry wood landed in the fresh market and lifted its median. When a shop gives a figure and no word, the figure now decides: up to 20 % is dry, up to 30 % partly dry, above that fresh. A word the shop writes still wins over it.
  • „Špalek“ in the singular is unsplit, like „špalky“. „Špalek na štípání“ had been read as split wood because of the word „štípání“ in it.

0.30.6 — 2026-10-07

Fixed

  • Two runs of the same recipe no longer collide. A new recipe is due at once, so the scheduler started it in the same second as a run started by hand; both inserted the same new offers and one failed on the unique key. Runs of one recipe now wait for each other.

0.30.5 — 2026-10-07

Fixed

  • Klády and wood „v celých délkách“ are counted as unsplit. Whole trunks in full length were read as ready-to-burn wood and would have dragged the split-wood median down by a third. The word is matched whole, because its letters also sit inside „skládané“ — stacked split wood, the opposite of a log.

0.30.4 — 2026-10-07

Fixed

  • Dvoumetrovky are counted as unsplit wood. Nobody splits a two-metre piece; every hand entry already had them apart from ready-to-burn wood. Plain „metrovky“ stay as they were, because split metre logs are a common thing to sell.

0.30.3 — 2026-10-07

Fixed

  • Kuláče are counted as unsplit wood, as every hand-entered price already had them — log rounds a metre or two long, sold for the buyer to split, and kept out of the medians like roundwood.

0.30.2 — 2026-10-07

Fixed

  • Hiding an unsplit offer, or confirming its price, through the automation API answered with an error although the change had been saved. The offer was read back through the default view, which leaves unsplit wood out.

0.30.1 — 2026-10-07

Fixed

  • A package size from the recipe is applied only where the recipe’s unit is. A price list that sells most of its wood by the 1.6 m pallet but some of it in a stacked 1×1×1 m crate would have divided the crate price by 1.6 as well. A row that states its own unit now states its own package.
  • „Skládaný“ is read as a stacked metre and „SPRM“ as a loose one. The first was not recognised at all; the second contains „PRM“ and was taken for stacked wood, a third too dear per solid metre.

0.30.0 — 2026-10-07

Added

  • A price grid built with CSS can be read, not only a real table. Some shops lay their price list out as a flat run of boxes and let the stylesheet arrange them into columns; the number of columns is written only in the style. The price-table reader now cuts such a grid into rows by that number, so it reads the same price list whichever way it was built.

Fixed

  • Špalky and kolečka are counted as unsplit wood. Both are log rounds sawn to length and left for the buyer to split — a different market from ready-to-burn wood, and kept out of the medians the same way roundwood is.

0.29.3 — 2026-10-07

Fixed

  • „Polotvrdé“ was read as hard wood. The word contains „tvrdé“, so semi-hard broadleaf — sold by the shops as its own, cheaper grade — was compared against oak and beech. It is now its own thing and counts as a mixture, as the hand-entered prices always had it.

0.29.2 — 2026-10-07

Fixed

  • The error page and the page-not-found page are in Czech. Both were still the English text of the project template, and the error page also showed visitors advice meant for developers about switching the server environment. They now say what happened, offer a way back, and the error page keeps only the request code, which is what finds the fault in the log.

0.29.1 — 2026-10-07

Changed

  • The site addresses its visitors formally, as a Czech shop would. It had been written in the familiar form throughout, which reads as presumptuous to a stranger pricing up five thousand crowns of firewood. One sentence also stopped assuming the reader is a man.

0.29.0 — 2026-10-07

Added

  • A unit converter at /prepocet. One sawmill quotes a loose cubic metre, the next a stacked one and a third a tonne, and the difference between them is more than twofold — so two prices side by side mean nothing until they are in the same unit. The page converts both an amount and a price, and a converted price is put straight against the median of its own group, because the question behind the conversion is always whether the offer is any good. It also writes down where the conversion factors come from and how much they vary, so the numbers can be argued with.

0.28.0 — 2026-10-07

Added

  • A price no firewood costs no longer slips into the numbers. When a crawl works out something outside what the trade charges — a surcharge read as a price, a note about VAT, a phone number, kindling sold by the 10 kg bag at 57 600 Kč/plm — the offer is kept and shown with the reason, but stays out of the medians, the charts and the rankings until somebody says the price is real. The recipe health panel reports how many are waiting. The bounds are configuration, because they are a statement about today’s market: 700 to 15 000 Kč per solid cubic metre, set from the prices actually collected, with the cheapest honest offer being roundwood at 1 100 and the dearest seasoned hardwood in a crate at 9 000.

0.27.0 — 2026-10-07

Added

  • Price lists written in groups can be read. A line names the wood, the lengths and prices follow under it, then the next wood — so the species sits outside the offer and every row used to come out as an unknown species. What marks a heading is that it carries no price, which also reads the shops that write the heading exactly like the rows beneath it, in the same bold centred paragraph, where no selector could tell the two apart.

0.26.1 — 2026-10-07

Added

  • A recipe can say how much wood one price covers, for a price list sold entirely in packages that are named only in its heading — „Síťované palety 1,6 PRMs“ above the table and nothing but lengths and prices inside it. Without it every such price read as the price of one cubic metre, which is the size of the package too high. An amount written in the offer itself still wins.

0.26.0 — 2026-10-07

Added

  • Price lists written as a grid can be read. Half the sawmills publish the lengths down the side, the species across the top and a price where they meet. Until now none of them could be followed automatically, because every offer has to sit in one block and here a cell knows its species from the column heading and its length from the row heading. A recipe can now be set to read such a table; one cell becomes one offer, named after both of its headings. Which way round the table is laid out is not assumed.

Fixed

  • „Listnaté měkké“ was filed as an unspecified mixture instead of soft wood. It is not a mix of hard and soft — it is the soft broadleaves, the birch and the alder, and the shop says so in the second word. A stated hardness now outranks the botanical group; two hardnesses at once („mix tvrdé a měkké“) stay a mixture as before.

0.25.2 — 2026-10-07

Fixed

  • A price for more than one cubic metre was read as a price for one when the shop keeps the amount in its own column. A pallet of 1,67 prms for 4 100 Kč came out as 4 100 Kč per prms — two thirds too dear. The amount is now also read from the unit column, not only from the price text and the offer name.

0.25.1 — 2026-10-07

Added

  • A recipe can build an offer’s name out of several cells. A price table that keeps the wood, the length and the quantity in three separate columns has no single element to point at, and taking the whole row instead dragged the price and the order button into the name. A name selector written as a list now joins what its parts match, in the order the page prints them.

0.25.0 — 2026-10-07

Added

  • A recipe can state the length the shop cuts its logs to. Many listings say it once, on the page about the wood, and then sell plain „Bukové dřevo“ — so a crawl used to lose a detail a hand-written entry had, and the length premium is a tenth of the price. A length written into an offer’s own name still wins over the recipe’s.

0.24.6 — 2026-10-07

Fixed

  • Birch written as an adjective was not recognised as a species. Once the accents come off, „bříza“ leaves the stem „briz“ and „březové“ leaves „brez“, and only the first was in the table — so a shop that sells „Březové dřevo“ had it filed under an unknown species and left out of the birch comparison.

0.24.5 — 2026-10-07

Fixed

  • On a phone, a link in a table was only as tall as its line of text — half of what a finger reliably hits. Links in the collapsed table now fill a whole touch target.

0.24.4 — 2026-10-07

Added

  • The cost calculator links to the sellers again. The list answered who is cheapest and then left the reader to find them, which is the one thing they are going to do next. The seller's name now leads to its site and the offer's name to the page the price was read from. Thirty of the offers were typed in by hand and have no page of their own; those keep a plain name, and the seller above them is still a link.

0.24.3 — 2026-10-07

Fixed

  • A price with the delivery already inside it was compared with prices without one. It is higher than the same wood sold at the gate, and by however far the buyer happens to live, so it is not a price per cubic metre of wood at all. Such offers now stay out of every median, chart and ranking — the way wholesale and whole logs already do. The cost calculator keeps them, because there the whole bill is what is being compared and that is exactly where they belong.
  • "Celá ČR" stood among the regions as if it were one. It says how far a seller delivers, not where it is, so it is gone from the medians by region and from the map, and a buyer is no longer offered it as a place to live. Sellers who deliver everywhere still show up whichever region the buyer picks.

0.24.2 — 2026-10-07

Fixed

  • Sellers ended up in the middle of their region instead of in their town. The check that the answer came from the right region read only the first region named, and a Czech address names the district the same way it names the region — so "okres Olomouc" was compared with "Olomoucký kraj", did not match, and Olomouc was thrown away in favour of the region it lies in. All the names are compared now, and a region that does not match only pushes an answer down the list instead of discarding it: being wrong about the region should cost a few kilometres, not replace the town with the middle of a region.

0.24.1 — 2026-10-07

Fixed

  • Sellers were placed on the map by the wrong town. Asking for "Praha 5 – Zbraslav" found Zbraslavice, seventy kilometres away, and asking for "Plzeň, Plzeňský kraj" answered with the middle of the region instead of the city. The town is now looked up on its own and the region is checked against the answer, the narrowest match wins over the district it lies in, and the way addresses are actually written down — a district number in front, the nearest town in brackets — is unpicked first.
  • The seller list and the catalogue now show "tam i zpět" next to a rate per kilometre, so a doubled delivery is visible where the rate is.

0.24.0 — 2026-10-07

Changed

  • The cost calculator asks where to deliver, not how far it is. Nobody knows how many kilometres away a sawmill is; everybody knows their own address. The distance to each seller is worked out from it — the sellers are placed on the map from their town once, and the road distance is estimated as the straight line plus thirty percent, which is the usual detour here. Each row shows the kilometres it used, so the figure can be argued with.

Added

  • A seller can record that it counts the kilometres there and back, as several price lists say outright. It doubles the delivery, so it is asked rather than assumed.

0.23.1 — 2026-10-07

Fixed

  • The cost calculator laid all three hundred matching offers on one page. It pages twenty at a time now: the answer to "where do I buy this cheapest" is in the first few rows, and the rest is for whoever wants to see the whole market.

0.23.0 — 2026-10-06

Added

  • "Kolik mě to bude stát" — say how much wood you want, of what, roughly how far it has to travel, and get the sellers listed by what the whole thing would actually cost. The delivery is added to the total, which is the point: a seller two hundred crowns cheaper per cubic meter who charges fifty a kilometre is not cheaper for anyone forty kilometres away, and a comparison that stops at the wood says the opposite. Sellers who will not sell as little as was asked for are dimmed rather than hidden, with the smallest order they do sell. Where a seller does not say enough to work the trip out, the row says so instead of guessing — a guessed delivery would quietly decide the order of the list.

0.22.0 — 2026-10-06

Changed

  • A loose cubic meter now counts as 0.44 solid cubic meters, not 0.40. The old figure sat at the bottom of every range the sources give: TZB-info says 0.41, Wikipedia 0.385–0.50 citing Pergl (2021) and Kalousová (2022), krb-palivo 0.44, and two sites that give the step through the stacked metre — 0.625 and 0.67 — land on 0.44 both times when carried through. Eighty-five percent of the offers are quoted in loose metres, so every price per solid cubic meter drops by about eight percent. What the figures say about each other barely moves: the first six sellers in the cheapest-first list keep their places. The stacked metre stays at 0.65, which is the middle of a genuinely wide spread (0.58 to 0.80) rather than an edge of it.

Added

  • The stored prices per solid cubic meter can be worked out again, history included. The conversion happens once, when an offer is written, so changing a factor used to leave every stored figure on the old one — and recomputing the history too is what keeps the charts from showing a fall that never happened in any shop.

0.21.0 — 2026-10-06

Added

  • A seller can be marked as wholesale. Some sell only by the lorry load — forty-five cubic metres at a time — and their price per cubic metre looks like everyone else's while being nothing a household could buy. Such a seller now stays out of the medians, the charts, the map, the overview and the public catalogue, and appears in a section of the catalogue that only a signed-in operator sees. The data is kept, it simply stops pretending to be comparable.

Fixed

  • The automation channel could not be built at all on an instance with no model provider key: the price list reader needed a client, and without a key none was registered. A stand-in is now registered that explains what is missing when a feature actually needs a model, instead of the whole channel failing on every call.
  • The health report called the AI "configured" whenever a client object existed, which after the fix above was always. It now asks whether a model would really answer.

0.20.1 — 2026-10-06

Fixed

  • The front page tiles and the seasoning picker went on counting whole logs after the previous release took them out of everything else. Both had their own copy of the condition that decides which offers may be compared, so the new rule never reached them and the headline medians were still mixing logs with ready-to-burn wood. Every comparison now goes through the one place that defines it.

0.20.0 — 2026-10-06

Changed

  • Whole logs are no longer averaged in with ready-to-burn wood. A one or two metre log costs about half of split firewood per solid cubic meter, because the buyer still has to saw and split it — and with thirty-five such offers in the list, the ranking of the cheapest sellers had stopped telling the truth: Domažlické městské lesy came first at 1 538 Kč/plm while their split wood sells for 3 750. Logs are now kept out of the medians, the charts, the map and the catalogue, exactly as dry and fresh are kept apart, and the overview shows them on the "i neštípané" switch. Only wood the seller itself calls unsplit is excluded; silence means split, which is what a firewood listing sells by default.

Fixed

  • Seven offers of PALIS and Klaus Timber were listed twice. They are left over from the bug that stored detail links as file:///… on the server: the fix gave each offer a fresh entry and left the old one unavailable. They were not in any number, but they cluttered the administration and their links were broken, so they are hidden now.

0.19.1 — 2026-10-06

Fixed

  • Starting the price history again now really starts it from today. Deleting the old points left each offer with its newest one still carrying its own date, and for sellers entered by hand in September that date was weeks back — so the thirty-day change went on comparing today against a handful of September prices from the cheapest sellers and reported a bigger jump than the one being cleared away. The surviving points are moved to the moment of the reset. When a price was last confirmed is kept on the offer itself, so the warning about stale hand-entered prices is unaffected.

0.19.0 — 2026-10-06

Added

  • The price history can be started again from today, keeping the newest price of every offer. The comparisons measure a basket of offers, and when the basket changes — a whole region arriving at once — the thirty-day change reports a market move that never happened. Once the series carries such a step, starting it again is more honest than explaining the step away for a month. It refuses without an explicit confirmation and cannot be undone from inside the application.

0.18.0 — 2026-10-06

Added

  • Price lists published as a PDF or a photograph can be read. Three sellers were kept out of the comparison by exactly this: a crawler has nothing to grip on a scanned table. The model is handed the file whole and returns the rows, including whether the document dates itself and whether it says the prices carry VAT. Nothing is saved from it — the rows come back to be checked against the document and entered, because a model reading a scanned table will eventually put a number in the wrong row, and a wrong price is worse than a missing one. Available as read_price_list.

0.17.0 — 2026-10-06

Added

  • Ready-made selectors per e-shop platform. Czech firewood shops mostly run on a handful of systems, and every shop on the same system has the same markup, so the same selectors were being written again for each one. The recipe editor now has "Předvyplnit podle platformy": it downloads the listing, recognises Shoptet or WooCommerce and fills the selectors in. A hand-made site — still half the trade — says so plainly and leaves the AI suggestion as the next step. Also available as detect_platform.

0.16.0 — 2026-10-06

Added

  • Since when a price list applies, where the seller dates it. Until now the only date kept was the day the price was read, which says nothing about how fresh the figures are: a list dated eight months back is worth a phone call even if the crawler saw it this morning. The catalogue prints it and marks the ones older than half a year, which is a different season and a different market.

0.15.0 — 2026-10-06

Added

  • What a length costs, on the charts page: how much more a shop wants for 25 or 50 cm logs than for the ordinary 33 cm. It is measured inside one seller's own range, between the same species, seasoning and packing — across sellers the gap between a sawmill and a packed pallet is many times the gap between two lengths, so a plain comparison would measure the wrong thing. The card draws a length only once three sellers offer it and says plainly what is still missing.

0.14.0 — 2026-10-06

Added

  • The cheaper price of a larger order, where a seller offers one — "od 2 palet 5 900 místo 6 100". It shows under the ordinary price but does not enter the comparisons: a discount most buyers never qualify for would quietly make every seller look cheaper than it is.

0.13.0 — 2026-10-06

Added

  • The measured moisture, where a seller prints one. "Suché" covered everything from ten to twenty-five percent, which is a real difference in how the wood burns and what it costs. The figure now shows next to the word: "suché (10 %)". A range is read at its upper bound, because that is what the buyer is promised at worst, and a percentage is only taken from text that talks about moisture — the VAT rate and the discounts on the same page are not readings.

0.12.0 — 2026-10-06

Added

  • The small print that decides whether two prices may be compared at all: whether the seller's prices carry VAT, whether the trip is already in them, and the smallest amount it will sell. A price without VAT is a fifth lower than one with it and a price with delivery in it a third higher, so the marker sits right next to the number instead of in a note only the operator reads. Nothing is guessed — a seller that says nothing gets no marker.

0.11.0 — 2026-10-06

Added

  • A watch on the crawler. The failure that costs the most is the quiet one: a shop rewrites its template or drops the figures out of its price list, the run still reports success, and the stored prices go stale while everything looks fine. Paliva Stříbro did exactly that for days. The administration now lists the recipes worth checking — the last run failed or finished only partly, the listing lost a third of its offers, none of the offers has a usable price, or the recipe has not run for twice its interval — and an unclean run is written to the log as a warning. Also available to the agent as get_recipe_health.

0.10.2 — 2026-10-06

Fixed

  • Saving a recipe kept the delivery form on its old value. The form was offered in the editor and accepted by the API, which even answered as if it had been written, but the save dropped it.

0.10.1 — 2026-10-06

Added

  • A recipe can say how its shop hands the wood over, the same way it already says the unit and the seasoning. Most shops sell in one form and never write it on the offer, so without this the crawled sellers would all have stayed blank in the new column.
  • Sellers in the Central Bohemian region: Silvatica, Forest & Garden, Pila Jeneč, Dřevospektrum, FOWOOD, Suché palivové dřevo Benešov, Dřevo Štěchovice and Lesy Novák, plus OK Paliva, which delivers nationwide.

0.10.0 — 2026-10-06

Added

  • An offer now says how the wood arrives: tipped loose off a lorry, stacked, a net on a pallet, a crate, a bag, a container, or collected at the yard. It is read from the offer name where the shop writes it and can be set by hand, and it explains a good part of why two sellers quoting the same cubic meter are hundreds of crowns apart — packed wood on a pallet is dearer than the same wood tipped on the drive. The catalogue lists the forms each seller uses.

0.9.1 — 2026-10-06

Fixed

  • The seller catalogue lists the regions in Czech alphabetical order. It was sorting them by the English name they carry inside the application, so the order looked arbitrary on the page.

0.9.0 — 2026-10-06

Added

  • A catalogue of the sellers on its own page: who sells wood, where from, what the trip costs and what their wood goes for, dry and fresh apart. It is the place where the delivery terms are actually readable — until now they only sat in small print under the seller's name. Filter by region, search by name or town, and a seller that publishes no prices stays listed, because the question "who sells around here" is not the same as "who is cheapest".
  • The number of offers in the catalogue links to the overview with that seller already picked (/?prodejce=12).
  • More sellers in the Central Bohemian region and Prague.

0.8.1 — 2026-10-06

Fixed

  • An offer that names both families — "mix (tvrdé, měkké)" — is a mixture again. The first word won, so a mixture was filed under hard wood and compared against pure oak and beech.

0.8.0 — 2026-10-06

Added

  • A seller now carries what it charges for delivery: a rate per kilometre, a flat fee, a free radius and a free-above order value, plus the terms in the seller's own words for whatever the numbers do not catch. It shows under the seller's name in the overview, so an offer is not read as a final price when the trip costs another third of it. Empty means the seller does not say, not that the delivery is free. Prices themselves are untouched: the medians still compare the wood alone, because every shop measures the trip from a different yard.
  • Sellers in Prague and the Central Bohemian region, which the map had empty until now.

0.7.0 — 2026-10-06

Added

  • The application is listed on the TomLabs app store (apps.tomlabs.cz): the pipeline registers it after a successful deploy, and the store reads the running version and status from /health on its own. The catalogue entry comes from appstore.json, the README, the changelog and a new 256 px icon.
  • Visits are measured with GA4, through the shared consent banner: nothing reaches Google before a visitor presses "Přijmout", accept and decline are equally prominent, and a Cookies link in the footer reopens the banner. Without a measurement id the banner never appears and no tag is loaded, which is how it behaves while developing.
  • The region map can carry a base map: with a Mapy.com key the tiles are proxied through /api/tiles, so the key stays on the server and never reaches the page. Without a key the regions are drawn on the page's own paper; while developing, OpenStreetMap stands in.

Changed

  • The map sits at the top of the charts page, above the price curves.

0.6.0 — 2026-10-06

Added

  • A map of the regions on the charts page: the fourteen regions filled by the median price per solid cubic meter, with the number of sellers and offers in the tooltip. A region where a seller sits but publishes no price is hatched rather than coloured, and one with no seller stays empty, so the map answers "where do we have anything" as much as "where is wood cheap". The numbers are listed under the map as well. The shapes are the ones Katalog arboristů uses (ČÚZK, CC BY 4.0).

Fixed

  • Five offers of Paliva Stříbro are hidden: the shop removed the figures from its price list, so the stored prices could no longer be checked against anything. They come back as soon as the prices do.

0.5.0 — 2026-10-06

Changed

  • A new look, shared with Katalog arboristů and Stromopedie. The paper palette, Fraunces headings over Source Sans text, rounded cards with a soft shadow and the dark "forest at night" are taken over unchanged, so the tree sites read as siblings. The dark-first console look with the ember accent and the monospace figures is gone; prices keep their aligned figures in the body face. The side navigation stayed where it was; the status bar became a footer with the version and how fresh the data is.
  • The chart hues are unchanged and were re-checked against the new surfaces: on paper three of them stay below 3:1, which the labels and the table view cover, and on the night ground all eight pass.

0.4.4 — 2026-09-24

Changed

  • /health answers in the shared TomLabs format: status is Healthy, Degraded or Unhealthy (instead of ok), next to version, name (drevobot) and entries with the database check and whether an AI provider is configured. An unreachable database answers 503 without the raw error message. Health probes are logged only at the Verbose level.

0.4.3 — 2026-09-24

Changed

  • The theme switch in the app bar is a menu with three choices: Podle systému (the default, follows the operating system live), Světlý and Tmavý. The icon shows the current one and the choice is remembered in the browser. The command palette offers the same three.

Fixed

  • A theme picked by hand no longer flips back when the operating system switches between light and dark; only "Podle systému" follows it.

0.4.2 — 2026-09-23

Fixed

  • A window narrowed past the navigation breakpoint closes the drawer on its own now, instead of keeping the indent until the page is reloaded. The layout subscribes to the breakpoint rather than measuring the window once at startup.

0.4.1 — 2026-09-23

Fixed

  • The application now fits a phone screen. The content kept a 240 px indent even with the navigation closed, so every page was wider than the display and scrolled sideways. The drawer is bound two-way again and slides over the content on a small screen instead of squeezing it.
  • On a narrow screen the status bar drops the timestamp and carries the version instead, which the app bar no longer has room for; a table cell that holds nothing (the ignore button for a visitor who is not signed in) no longer takes a row of its own in the phone card layout.

0.4.0 — 2026-09-23

Added

  • Command palette on Ctrl+K, the one the desktop applications have: one search across the pages, the recipes, the manual price lists and a couple of actions. The letters only have to appear in order and diacritics do not matter, so "prec" finds "Prodejci a recepty" and typing the address ("grafy") works too. A signed-out visitor sees only the public pages.
  • A status bar along the bottom: how many sellers and offers there are, when the data was last updated, and a live indicator.

Changed

  • The selected row in the sidebar wears a 2px accent bar instead of a filled background, numbers in the median tiles are set in the monospace face, and the tiles fade up when a page opens.

0.3.1 — 2026-09-23

Changed

  • A new look, shared with the TomLabs desktop applications. The colours, hairline borders, small radii and the monospace face for technical values now come from one token layer (wwwroot/tomlabs.css) that mirrors the desktop "ops console" style. DrevoBot carries an ember accent of its own.
  • The price overview and the charts stay airy; the administration is drawn as the operator's console, with tighter rows and smaller type. Both wear the same colours.
  • The chart palette now follows the application's theme switch as well. Before it followed only the operating system, so a chart kept its light colours on a dark page after using the toggle.

0.3.0 — 2026-09-21

Added

  • The administration is behind a sign-in. Vendors, recipes, the run history, the log and the ignore button on the overview need the administrator password (Admin:Password, ADMIN_PASSWORD in .env, AdminPassword in the pipeline). The price overview and the charts stay public. Without a configured password nobody can sign in. The session lasts a month.

0.2.2 — 2026-09-21

Added

  • An offer can be ignored from the overview (the eye button in its row): sawmill offcuts, sawdust or a container of unknown volume drop out of the table, the medians and the charts while staying tracked, so the crawler does not bring them back. The switch "i ignorované" shows them again and the same button restores one. Also available as the ignore_offer tool.

0.2.1 — 2026-09-19

Fixed

  • A card naming several species is a mixture again: "Tvrdé dřevo (dub, buk, jasan, akát)" was filed under beech and "smrk, borovice, modřín" under spruce, so the per-species chart showed mixture prices as if they were single species. Two or more named species now give the hardwood or softwood mix, a family word alone ("tvrdé", "listnaté", "měkké") gives that family's mix.
  • Relative links to offer details were stored as file:///… on the server, because Linux takes a path like /produkt/buk for an absolute file address. They are resolved against the shop page again; the affected offers get a fresh entry and the old one keeps its history as unavailable.
  • A seller switched off in the admin UI now drops out of the overview, the medians and the charts, hand-entered prices included. Before, only its crawler stopped.
  • Pages in windows-1250 (still common on small Czech sites) were read as UTF-8 and came out as replacement characters. The charset from the header or the page's meta tag is honoured now.

0.2.0 — 2026-09-18

Added

  • Automation channel for an agent. The application now embeds an MCP server at /mcp (Streamable HTTP, stateless) and a REST mirror at /api/automation, so Claude can fill and drive it without the admin UI: list, create, update and delete vendors and recipes, inspect a shop page (condensed HTML or the elements a selector matches), dry-run a recipe draft with the full parsing diagnostics, run a recipe for real, read the offers, the market summary, the species and vendor statistics, keep manual price lists and read the application log.
  • Both paths are protected by one bearer token (Automation:Token, AUTOMATION_TOKEN in .env on the server, AutomationToken in the pipeline variable group). Without a token the channel answers 503 and exposes nothing.
  • .mcp.json in the repository registers the production and the local server for Claude Code.

Changed

  • The container runs with workstation GC. The .NET runtime 10.0.12 fails to start server GC on the OpenVZ host of the VPS (an empty CPU affinity set), which broke both the build and the app.

0.1.8 — 2026-09-07

  • The dry and fresh medians on the front page are split once more into hard and soft wood.
  • Birch, alder, poplar and willow count as soft wood, as the trade sells them.

0.1.7 — 2026-09-07

  • A recipe can point a Dřevina selector at the text the species is written in, for cards that name it away from the offer name.

0.1.6 — 2026-09-07

  • The parsing test reports what happened at every step: HTML size, structured data, how many blocks each selector hit and what it read, and the raw texts of the first blocks.
  • When the item selector matches nothing, repeating blocks that contain a price are suggested.

0.1.5 — 2026-09-07

  • An offer that says nothing about moisture is taken to be fresh wood; the level is called „čerstvé“ throughout.

0.1.4 — 2026-09-07

  • Manual price lists for sellers who publish no prices; entries older than ninety days are flagged for a fresh call.

0.1.3 — 2026-09-07

  • Dry and fresh wood are kept apart in every price aggregate: separate medians, separate trend series, comparisons only inside one group.

0.1.2 — 2026-09-07

  • The moisture of an offer is shown wherever offers are listed and compared.

0.1.1 — 2026-09-07

  • Fixed the production smoke test and added a real favicon.

0.1.0 — 2026-09-06

  • First release: vendors with scraping recipes, a background crawler, conversion of every price to a solid cubic meter, the offer overview, trend charts, vendor discovery and an application log.