Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes are listed here. Format based on [Keep a Changelog](https://keepachangelog.com/).

## Unreleased

### Added
- `GovSource` (`gov`): central-government tenders from mr.gov.il, read from the portal's HTML search. It is part of
the default sources.
- Tests use a fixed clock, so open/closed results no longer depend on the date the suite runs.
- `HttpClient.get_text` for sources that serve HTML.

## 0.1.0 (2026-11-05)

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

Israeli public tenders (מכרזים) in Python. One `Tender` object, whichever site it came from.

Sources today: Israel Land Authority land tenders and Beer Sheva municipal tenders.
Sources today: Israel Land Authority land tenders, Beer Sheva municipal tenders, and central-government tenders
from mr.gov.il. The first search reads about 25 pages from mr.gov.il, so it takes around 20 seconds. After that the
responses are cached for an hour.

> Unofficial client. It can lag or miss tenders, and parsing can break when a source changes. Confirm deadlines on
> the publisher's page before you act. Not affiliated with the Israel Land Authority or any municipality.
Expand Down
Binary file added docs/images/pr-gov-check.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-gov-cli.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-gov-tests.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions docs/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ Keys are the members of `michraz.RmiExtra`, a string enum: `tender.extra[RmiExtr
| `khal_yaad_rashi` | `KhalYaadRashi`, kept as is. Observed values are 1, 2 and null, so it is a code and not a bid amount. |
| `mekuvan` | `Mekuvan`, kept as is. Its meaning is unconfirmed. |

### Government `extra`

Keys are the members of `michraz.GovExtra`: `type` (for example `מכרז פומבי`), `status` (the portal's status, for
example `עודכן`) and `raw`. `source_id` is the portal's publication number, `number` is its procedure number, and
`documents` is empty because detail pages are not fetched.

## `Document`

| Field | Type | Meaning |
Expand Down
19 changes: 19 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ correct and not confirmed.
- 402 rows have a winner, 51 have no files. `קבצים` is HTML that is not always wrapped in `<p>`, and some links
encode the URL as `http&#58;//...`. It is read with `html.parser`.

## Central government, `gov`

- Portal: the Government Procurement Administration's tender site, `https://mr.gov.il/ilgstorefront/he/search/`.
It has no JSON API. The search page is server-rendered HTML, 20 results per page, `page` starts at 0.
- Attribution: `Government Procurement Administration (מינהל הרכש הממשלתי), mr.gov.il`. The license of the data was
not checked.
- Query: `q=:updateDate:archive:false:status:<status>`, `s=TENDER`. The portal reports 8 835 tenders in total, 7 654
of them with status `חלף מועד הגשה` (submission date passed). michraz reads only the statuses `פורסם`, `עודכן` and
`חדש`.
- Verified on 2026-11-05: those three statuses returned 123, 256 and 40 tenders. Reading all of them gave 419
tenders and 0 parse errors in about 21 seconds. 183 had a deadline still ahead and 14 had no deadline.
- Fields read: title, type (`מכרז פומבי`), publisher, publication number (used as `source_id`), procedure number
(used as `number`), publication date, update date, deadline (`dd/mm/yyyy HH:MM`) and status.
- Not verified: statuses `בעדכון` (32) and `עתידי` (15) are not read, because it is unclear whether they accept
offers. Detail pages and attached documents are not fetched, so `documents` is empty.
- `tests/fixtures/gov_search.html` is five real result blocks copied from a live page. The embedded images were
removed and the count sentence was edited to say 5 so that it matches the excerpt.
- The site has no `robots.txt` (the request returns an empty body).

## Robots and terms

Checked 2026-11-04 from the development machine with the michraz User-Agent.
Expand Down
13 changes: 12 additions & 1 deletion src/michraz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,23 @@
from michraz.errors import MichrazError, SourceFormatError, SourceUnavailableError
from michraz.export import to_csv, to_dataframe, to_dicts
from michraz.models import Document, Tender
from michraz.sources import BeerShevaExtra, BeerShevaSource, RmiExtra, RmiSource, Source, SourceName
from michraz.sources import (
BeerShevaExtra,
BeerShevaSource,
GovExtra,
GovSource,
RmiExtra,
RmiSource,
Source,
SourceName,
)

__all__ = [
"BeerShevaExtra",
"BeerShevaSource",
"Document",
"GovExtra",
"GovSource",
"Michraz",
"MichrazError",
"RmiExtra",
Expand Down
32 changes: 24 additions & 8 deletions src/michraz/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class HttpClient(Closeable):
min_interval: Minimum seconds between requests to the same host. Must be at least 0.5.
client: An ``httpx.Client`` to use. By default one is created and owned by this object.
clock: Monotonic clock in seconds. Replace it in tests.
sleep: Sleep function. Replace it in tests so they do not wait.
sleep: Sleep function. Defaults to ``time.sleep``, looked up when the client is created.
wall_clock: Wall-clock time in seconds since the epoch, used for cache age.

Raises:
Expand All @@ -150,14 +150,14 @@ def __init__(
min_interval: float = DEFAULT_MIN_INTERVAL,
client: httpx.Client | None = None,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
sleep: Callable[[float], None] | None = None,
wall_clock: Callable[[], float] = time.time,
) -> None:
if min_interval < MIN_INTERVAL_FLOOR:
raise ValueError(f"min_interval must be at least {MIN_INTERVAL_FLOOR} seconds")
self.min_interval = min_interval
self._clock = clock
self._sleep = sleep
self._sleep = sleep or time.sleep
self._wall_clock = wall_clock
self._owns_client = client is None
self._client = client or httpx.Client(
Expand Down Expand Up @@ -207,16 +207,32 @@ def cached_at(
def _full_url(url: str, params: Mapping[str, Any] | None) -> str:
return str(httpx.URL(url, params=params)) if params else url

def get_text(self, source: str, url: str, params: Mapping[str, Any] | None = None) -> str:
"""Fetches ``url`` with GET and returns the body as text. Cached and rate-limited like JSON.

Raises:
SourceUnavailableError: If the host cannot be reached or answers with an error status.
"""
text: str = self._fetch(source, HttpMethod.GET, self._full_url(url, params), None, lambda r: r.text)
return text

def _json(self, source: str, method: HttpMethod, url: str, payload: Any) -> Any:
def decode(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError as exc:
raise SourceFormatError(source, f"response is not JSON: {_truncate(response.text)!r}") from exc

return self._fetch(source, method, url, payload, decode)

def _fetch(
self, source: str, method: HttpMethod, url: str, payload: Any, decode: Callable[[httpx.Response], Any]
) -> Any:
key = _request_key(method, url, payload)
if self._cache and (hit := self._cache.fresh(key)) is not None:
logger.debug("cache hit for %s", url)
return hit[1]
response = self._send(source, method, url, payload)
try:
data = response.json()
except ValueError as exc:
raise SourceFormatError(source, f"response is not JSON: {_truncate(response.text)!r}") from exc
data = decode(self._send(source, method, url, payload))
if self._cache:
self._cache.write(key, data)
return data
Expand Down
8 changes: 5 additions & 3 deletions src/michraz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
)
from michraz.filters import TenderFilter, sort_by_deadline
from michraz.models import Tender
from michraz.sources import BaseSource, BeerShevaSource, RmiSource, Source
from michraz.sources import BaseSource, BeerShevaSource, GovSource, RmiSource, Source

logger = logging.getLogger(__name__)

Expand All @@ -30,7 +30,7 @@ class Michraz(Closeable):
tenders = m.search(keyword="גינון", open_only=True)

Args:
sources: Sources to search. Defaults to RMI and Beer Sheva.
sources: Sources to search. Defaults to RMI, Beer Sheva and the central government.
cache_dir: Directory for cached responses. Defaults to ``~/.cache/michraz``. None disables the cache.
cache_ttl: How long a cached response stays valid.
min_interval: Minimum seconds between requests to the same host. Must be at least 0.5.
Expand All @@ -51,7 +51,9 @@ def __init__(
min_interval: float = DEFAULT_MIN_INTERVAL,
) -> None:
self._http = HttpClient(cache_dir=cache_dir, cache_ttl=cache_ttl, min_interval=min_interval)
self.sources: tuple[Source, ...] = tuple(sources) if sources is not None else (RmiSource(), BeerShevaSource())
self.sources: tuple[Source, ...] = (
tuple(sources) if sources is not None else (RmiSource(), BeerShevaSource(), GovSource())
)
names = [s.name for s in self.sources]
if len(set(names)) != len(names):
raise ValueError(f"source names must be unique, got {names}")
Expand Down
13 changes: 12 additions & 1 deletion src/michraz/sources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

from michraz.sources.base import BaseSource, Source, SourceName
from michraz.sources.beersheva import BeerShevaExtra, BeerShevaSource
from michraz.sources.gov import GovExtra, GovSource
from michraz.sources.rmi import RmiExtra, RmiSource

__all__ = ["BaseSource", "BeerShevaExtra", "BeerShevaSource", "RmiExtra", "RmiSource", "Source", "SourceName"]
__all__ = [
"BaseSource",
"BeerShevaExtra",
"BeerShevaSource",
"GovExtra",
"GovSource",
"RmiExtra",
"RmiSource",
"Source",
"SourceName",
]
1 change: 1 addition & 0 deletions src/michraz/sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class SourceName(StrEnum):

RMI = "rmi"
BEERSHEVA = "beersheva"
GOV = "gov"


@runtime_checkable
Expand Down
Loading
Loading