diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2deb94b..a825f4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 6ae9e31..6d27265 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docs/images/pr-gov-check.png b/docs/images/pr-gov-check.png
new file mode 100644
index 0000000..4f2d158
Binary files /dev/null and b/docs/images/pr-gov-check.png differ
diff --git a/docs/images/pr-gov-cli.png b/docs/images/pr-gov-cli.png
new file mode 100644
index 0000000..edb080c
Binary files /dev/null and b/docs/images/pr-gov-cli.png differ
diff --git a/docs/images/pr-gov-tests.png b/docs/images/pr-gov-tests.png
new file mode 100644
index 0000000..518e6f8
Binary files /dev/null and b/docs/images/pr-gov-tests.png differ
diff --git a/docs/model.md b/docs/model.md
index 707faab..db6bedf 100644
--- a/docs/model.md
+++ b/docs/model.md
@@ -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 |
diff --git a/docs/sources.md b/docs/sources.md
index c9bfca2..61c4bfc 100644
--- a/docs/sources.md
+++ b/docs/sources.md
@@ -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 `
`, and some links
encode the URL as `http://...`. 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:`, `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.
diff --git a/src/michraz/__init__.py b/src/michraz/__init__.py
index 7ea4903..40f835c 100644
--- a/src/michraz/__init__.py
+++ b/src/michraz/__init__.py
@@ -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",
diff --git a/src/michraz/_http.py b/src/michraz/_http.py
index cdfd3c8..55d5442 100644
--- a/src/michraz/_http.py
+++ b/src/michraz/_http.py
@@ -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:
@@ -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(
@@ -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
diff --git a/src/michraz/client.py b/src/michraz/client.py
index d26fc15..05f7a56 100644
--- a/src/michraz/client.py
+++ b/src/michraz/client.py
@@ -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__)
@@ -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.
@@ -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}")
diff --git a/src/michraz/sources/__init__.py b/src/michraz/sources/__init__.py
index afed88e..24f234a 100644
--- a/src/michraz/sources/__init__.py
+++ b/src/michraz/sources/__init__.py
@@ -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",
+]
diff --git a/src/michraz/sources/base.py b/src/michraz/sources/base.py
index d423c7e..1a00d85 100644
--- a/src/michraz/sources/base.py
+++ b/src/michraz/sources/base.py
@@ -22,6 +22,7 @@ class SourceName(StrEnum):
RMI = "rmi"
BEERSHEVA = "beersheva"
+ GOV = "gov"
@runtime_checkable
diff --git a/src/michraz/sources/gov.py b/src/michraz/sources/gov.py
new file mode 100644
index 0000000..29d302a
--- /dev/null
+++ b/src/michraz/sources/gov.py
@@ -0,0 +1,219 @@
+"""Central government tenders from the Government Procurement Administration portal (mr.gov.il).
+
+The portal has no JSON API. Its search page is server-rendered HTML, so this source parses it.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterator
+from datetime import datetime
+from html.parser import HTMLParser
+from typing import Any
+
+from michraz import _dates
+from michraz._enums import StrEnum
+from michraz.errors import SourceFormatError
+from michraz.models import RAW_EXTRA_KEY, Tender
+from michraz.sources.base import BaseSource, SourceName
+
+SEARCH_URL = "https://mr.gov.il/ilgstorefront/he/search/"
+TENDER_URL = "https://mr.gov.il/ilgstorefront/he/p/{}"
+ATTRIBUTION = "Government Procurement Administration (מינהל הרכש הממשלתי), mr.gov.il"
+DATE_FORMAT = "%d/%m/%Y"
+DATETIME_FORMAT = "%d/%m/%Y %H:%M"
+LABEL_SUFFIX = ":"
+SEPARATOR = "|"
+
+_RESULT_CLASS = "result-container"
+_TITLE_CLASS = "search-results-content-head"
+_TYPE_CLASS = "search-result-sub-head"
+_TENDER_LINK = re.compile(r"^/ilgstorefront/he/p/(\d+)$")
+_TOTAL = re.compile(r"נמצאו ([\d,]+) תוצאות")
+
+
+class _Status(StrEnum):
+ """Status facet values of the portal that mean a tender is still collecting offers."""
+
+ PUBLISHED = "פורסם"
+ UPDATED = "עודכן"
+ NEW = "חדש"
+
+
+class _Label(StrEnum):
+ """Labels printed next to each value in a search result."""
+
+ PUBLISHER = "שם המפרסם"
+ PUBLICATION_NUMBER = "מס' פרסום"
+ STATUS = "סטטוס"
+ PROCEDURE_NUMBER = "מס׳ הליך"
+ PUBLISHED = "תאריך פרסום"
+ UPDATED = "תאריך עדכון"
+ DEADLINE = "מועד אחרון להגשה"
+
+
+class GovExtra(StrEnum):
+ """Keys of ``Tender.extra`` for government tenders."""
+
+ TYPE = "type"
+ STATUS = "status"
+ RAW = RAW_EXTRA_KEY
+
+
+class _ResultParser(HTMLParser):
+ """Splits a search page into one dict per result: ``id``, ``title``, ``type`` and the labelled values."""
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.results: list[dict[str, Any]] = []
+ self._capture: str | None = None
+ self._buffer: list[str] = []
+ self._spans: list[str] = []
+
+ def _current(self) -> dict[str, Any] | None:
+ return self.results[-1] if self.results else None
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ values = dict(attrs)
+ classes = (values.get("class") or "").split()
+ if tag == "div" and _RESULT_CLASS in classes:
+ self._finish()
+ self.results.append({"id": None, "title": None, "type": None, "fields": {}})
+ elif (result := self._current()) is None:
+ return
+ elif tag == "a" and result["id"] is None and (m := _TENDER_LINK.match(values.get("href") or "")):
+ result["id"] = m.group(1)
+ elif tag == "h2" and _TITLE_CLASS in classes:
+ self._start("title")
+ elif tag == "h3" and _TYPE_CLASS in classes:
+ self._start("type")
+ elif tag == "span":
+ self._start("span")
+
+ def _start(self, target: str) -> None:
+ self._capture, self._buffer = target, []
+
+ def handle_data(self, data: str) -> None:
+ if self._capture:
+ self._buffer.append(data)
+
+ def handle_endtag(self, tag: str) -> None:
+ if self._capture is None or tag not in {"h2", "h3", "span"}:
+ return
+ text = " ".join("".join(self._buffer).split())
+ if self._capture == "span":
+ if text:
+ self._spans.append(text)
+ elif (result := self._current()) is not None:
+ result[self._capture] = text or None
+ self._capture = None
+
+ def _finish(self) -> None:
+ result = self._current()
+ if result is None:
+ return
+ spans, fields = self._spans, result["fields"]
+ for position, text in enumerate(spans):
+ following = spans[position + 1] if position + 1 < len(spans) else None
+ if text.endswith(LABEL_SUFFIX) and following and not following.endswith(LABEL_SUFFIX):
+ if following != SEPARATOR:
+ fields[text.removesuffix(LABEL_SUFFIX).strip()] = following
+ self._spans = []
+
+ def close(self) -> None:
+ super().close()
+ self._finish()
+
+
+def parse_page(page: str) -> tuple[list[dict[str, Any]], int]:
+ """Returns the results on one search page and the total result count the portal reports.
+
+ Raises:
+ ValueError: If the page has no result count, so it is not a search page.
+ """
+ match = _TOTAL.search(page)
+ if match is None:
+ raise ValueError("no result count on the page")
+ parser = _ResultParser()
+ parser.feed(page)
+ parser.close()
+ return parser.results, int(match.group(1).replace(",", ""))
+
+
+class GovSource(BaseSource):
+ """Tenders of ministries and other central-government bodies, from mr.gov.il.
+
+ Only tenders whose portal status says they are still taking offers are read (published, updated, new).
+ The portal returns 20 results per page, so a full read takes a few dozen requests. They are cached.
+
+ Args:
+ **settings: ``cache_dir``, ``cache_ttl`` and ``min_interval``, as for :class:`BaseSource`.
+ """
+
+ name = SourceName.GOV.value
+ description = "Central government tenders (mr.gov.il)"
+ attribution = ATTRIBUTION
+
+ @staticmethod
+ def _params(status: _Status, page: int) -> dict[str, Any]:
+ return {"q": f":updateDate:archive:false:status:{status}", "text": "", "s": "TENDER", "page": page}
+
+ def last_fetched(self) -> datetime | None:
+ return None
+
+ def fetch(self) -> Iterator[Tender]:
+ """Yields every open tender, skipping records that fail to parse.
+
+ Raises:
+ SourceUnavailableError: If mr.gov.il cannot be reached.
+ SourceFormatError: If a page is not a portal search page.
+ """
+ self.errors = []
+ http = self._client()
+ seen: set[str] = set()
+ for status in _Status:
+ page, read = 0, 0
+ while True:
+ text = http.get_text(self.name, SEARCH_URL, self._params(status, page))
+ try:
+ results, total = parse_page(text)
+ except ValueError as exc:
+ raise SourceFormatError(self.name, f"not a portal search page ({exc})") from exc
+ for result in results:
+ try:
+ tender = self.parse(result)
+ except (KeyError, ValueError) as exc:
+ self._skip(result.get("id"), exc)
+ continue
+ if tender.source_id not in seen:
+ seen.add(tender.source_id)
+ yield tender
+ read += len(results)
+ page += 1
+ if not results or read >= total:
+ break
+
+ def parse(self, result: dict[str, Any]) -> Tender:
+ """Converts one parsed search result to a :class:`Tender`.
+
+ Raises:
+ KeyError: If a required value is missing.
+ ValueError: If a date does not match the portal format.
+ """
+ source_id, title = result["id"], result["title"]
+ if not source_id or not title:
+ raise KeyError("id or title")
+ fields: dict[str, str] = result["fields"]
+ return Tender(
+ source=self.name,
+ source_id=source_id,
+ title=title,
+ number=fields.get(_Label.PROCEDURE_NUMBER),
+ publisher=fields.get(_Label.PUBLISHER),
+ published_at=_dates.parse_local(fields.get(_Label.PUBLISHED), DATE_FORMAT),
+ updated_at=_dates.parse_local(fields.get(_Label.UPDATED), DATE_FORMAT),
+ deadline=_dates.parse_local(fields.get(_Label.DEADLINE), DATETIME_FORMAT),
+ url=TENDER_URL.format(source_id),
+ attribution=self.attribution,
+ extra={GovExtra.TYPE: result["type"], GovExtra.STATUS: fields.get(_Label.STATUS), GovExtra.RAW: result},
+ )
diff --git a/tests/conftest.py b/tests/conftest.py
index 6dc9653..e978a92 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,13 +1,28 @@
import socket
from collections.abc import Callable
+from datetime import datetime
from typing import Any
import pytest
+from michraz._dates import JERUSALEM
from michraz._http import HttpClient
from tests.helpers import FakeClock, load_fixture
+@pytest.fixture(autouse=True)
+def _pinned_clock(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Fixes "now" to the day the fixtures were recorded, so open/closed results do not drift."""
+ monkeypatch.setattr("michraz._dates.now", lambda: datetime(2026, 11, 4, 12, 0, tzinfo=JERUSALEM))
+
+
+@pytest.fixture(autouse=True)
+def _no_real_sleep(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Skips the rate-limit wait of clients built inside a test. Live tests keep the real wait."""
+ if not request.node.get_closest_marker("live"):
+ monkeypatch.setattr("time.sleep", lambda seconds: None)
+
+
@pytest.fixture(autouse=True)
def _no_real_network(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None:
"""Fails any test that tries to open a real socket, unless it is marked ``live``."""
diff --git a/tests/fixtures/gov_search.html b/tests/fixtures/gov_search.html
new file mode 100644
index 0000000..4d29660
--- /dev/null
+++ b/tests/fixtures/gov_search.html
@@ -0,0 +1,343 @@
+נמצאו 5 תוצאות עבור ערך במכרזים
+
+
+
+
+
+

+
+
+
+
+
![מכרזים]()
+
מכרז פומבי
+
+
+
+
+
+
+
+
+
+
+
+

+
+
מכרז פומבי
+
אספקת שירותי דפוס עבור אגף בכיר קשרי חוץ ודוברות ומעונות המשרד
+
+
+
+
שם המפרסם: משרד הרווחה
+
+
מס' פרסום: 4000621860
+
|
+
סטטוס: עודכן
+
|
+
+
+ מס׳ הליך: 734/2026
+
+
|
+
+
+ תאריך פרסום: 14/09/2026
+
+
+
+
+ תאריך עדכון: 04/11/2026
+
+
+
|
+
+
+ מועד אחרון להגשה: 09/12/2026 12:00
+
+
+
+
+
+
+
+
+
+
+
![מכרזים]()
+
מכרז פומבי
+
+
+
+
+
+
+
+
+
+
+
+

+
+
מכרז פומבי
+
לרכש סל פריטים משקי חד"פ
+
+
+
+
שם המפרסם: משרד הבריאות - המרכז הרפואי תל אביב ע"ש סוראסקי, איכילוב
+
+
מס' פרסום: 4000623437
+
|
+
סטטוס: עודכן
+
|
+
+
+ מס׳ הליך: 265647
+
+
|
+
+
+ תאריך פרסום: 28/10/2026
+
+
+
+
+ תאריך עדכון: 04/11/2026
+
+
+
|
+
+
+ מועד אחרון להגשה: 09/11/2026 12:00
+
+
+
+
+
+
+
+
+
+
+
![מכרזים]()
+
מכרז פומבי
+
+
+
+
+
+
+
+
+
+
+
+

+
+
מכרז פומבי
+
מכרז 11/2026
+לרישוי, מתן שירותי תמיכה ותחזוקה למערך ה-SIEM
+
+
+
+
+
שם המפרסם: משרד הפנים
+
+
מס' פרסום: 4000623757
+
|
+
סטטוס: פורסם
+
|
+
+
+ מס׳ הליך: 11/2026
+
+
|
+
+
+ תאריך פרסום: 04/11/2026
+
+
+
+
+ תאריך עדכון: 04/11/2026
+
+
+
|
+
+
+ מועד אחרון להגשה: 15/12/2026 14:00
+
+
+
+
+
+
+
+
+
+
+
![מכרזים]()
+
מכרז פומבי
+
+
+
+
+
+
+
+
+
+
+
+

+
+
מכרז פומבי
+
להתקנת תשתיות לגז טבעי
+ולהסבת מערכות בית החולים
+במרכז הרפואי זיו
+
+
+
+
שם המפרסם: משרד הבריאות - המרכז הרפואי זיו
+
+
מס' פרסום: 4000623755
+
|
+
סטטוס: פורסם
+
|
+
+
+ מס׳ הליך: 15/2026
+
+
|
+
+
+ תאריך פרסום: 04/11/2026
+
+
+
+
+ תאריך עדכון: 04/11/2026
+
+
+
|
+
+
+ מועד אחרון להגשה: 20/12/2026 14:00
+
+
+
+
+
+
+
+
+
+
+
![מכרזים]()
+
מכרז פומבי
+
+
+
+
+
diff --git a/tests/helpers.py b/tests/helpers.py
index 7e11cc4..28d197a 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -41,3 +41,6 @@ def make_tender(**overrides: Any) -> Tender:
fields: dict[str, Any] = {"source": "test", "source_id": "1", "title": "title", "attribution": "credit"}
fields.update(overrides)
return Tender(**fields)
+
+
+GOV_EMPTY_PAGE = "נמצאו 0 תוצאות עבור ערך במכרזים
"
diff --git a/tests/live/test_live_smoke.py b/tests/live/test_live_smoke.py
index 7e1c81c..6788ef9 100644
--- a/tests/live/test_live_smoke.py
+++ b/tests/live/test_live_smoke.py
@@ -6,7 +6,7 @@
import pytest
-from michraz import BeerShevaSource, RmiSource
+from michraz import BeerShevaSource, GovSource, RmiSource
pytestmark = pytest.mark.live
@@ -33,3 +33,11 @@ def test_beersheva_shape():
tender = next(iter(source.fetch()))
assert tender.title and tender.source_id and tender.url
assert {"שם מכרז", "מספר מכרז", "תאריך אחרון להגשה", "זוכה במכרז", "קבצים", "URL"} <= set(tender.extra["raw"])
+
+
+def test_gov_first_page_shape():
+ with GovSource(cache_dir=None) as source:
+ tender = next(iter(source.fetch()))
+ assert tender.title and tender.source_id and tender.publisher
+ assert tender.url.startswith("https://mr.gov.il/")
+ assert not source.errors
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 5c957fd..fdfd94f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -6,14 +6,16 @@
from michraz import __version__
from michraz.cli import main
from michraz.sources.beersheva import API_URL
+from michraz.sources.gov import SEARCH_URL as GOV_URL
from michraz.sources.rmi import SEARCH_URL
-from tests.helpers import ckan
+from tests.helpers import GOV_EMPTY_PAGE, ckan
@pytest.fixture
def served(respx_mock, rmi_records, bs_records):
respx_mock.post(SEARCH_URL).respond(json=rmi_records)
respx_mock.get(API_URL).respond(json=ckan(bs_records))
+ respx_mock.get(GOV_URL).respond(text=GOV_EMPTY_PAGE)
def run(capsys, *argv):
diff --git a/tests/test_client.py b/tests/test_client.py
index 8a8dd7a..5c64ee2 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -5,8 +5,9 @@
from michraz import Michraz, RmiSource, SourceUnavailableError, Tender
from michraz.sources.beersheva import API_URL
+from michraz.sources.gov import SEARCH_URL as GOV_URL
from michraz.sources.rmi import SEARCH_URL
-from tests.helpers import at, ckan, make_tender
+from tests.helpers import GOV_EMPTY_PAGE, at, ckan, make_tender
class FakeSource:
@@ -26,6 +27,7 @@ def fetch(self) -> Iterator[Tender]:
def served(respx_mock, rmi_records, bs_records):
respx_mock.post(SEARCH_URL).respond(json=rmi_records)
respx_mock.get(API_URL).respond(json=ckan(bs_records))
+ respx_mock.get(GOV_URL).respond(text=GOV_EMPTY_PAGE)
def test_searches_every_default_source_sorted_by_deadline(served):
@@ -114,7 +116,7 @@ def test_second_search_uses_the_cache(respx_mock, rmi_records, tmp_path):
def test_repr_lists_source_names():
with Michraz(cache_dir=None) as m:
- assert repr(m) == "Michraz(sources=['rmi', 'beersheva'])"
+ assert repr(m) == "Michraz(sources=['rmi', 'beersheva', 'gov'])"
def test_base_source_is_abstract():
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index 2650c5f..3fbe2e9 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -4,20 +4,23 @@
import json
import subprocess
import sys
+from datetime import date
import pytest
from michraz import Michraz, to_csv, to_dicts
from michraz.cli import main
from michraz.sources.beersheva import API_URL
+from michraz.sources.gov import SEARCH_URL as GOV_URL
from michraz.sources.rmi import SEARCH_URL
-from tests.helpers import ckan
+from tests.helpers import FIXTURES, GOV_EMPTY_PAGE, ckan
@pytest.fixture
def services(respx_mock, rmi_records, bs_records):
rmi = respx_mock.post(SEARCH_URL).respond(json=rmi_records)
beersheva = respx_mock.get(API_URL).respond(json=ckan(bs_records))
+ respx_mock.get(GOV_URL).respond(text=GOV_EMPTY_PAGE)
return rmi, beersheva
@@ -58,6 +61,7 @@ def test_cli_end_to_end_with_a_warm_cache(services, tmp_path, monkeypatch, capsy
def test_one_bad_record_does_not_kill_the_batch(respx_mock, rmi_records, bs_records):
respx_mock.post(SEARCH_URL).respond(json=[*rmi_records, {"MichrazID": 1, "SgiraDate": "garbage"}])
respx_mock.get(API_URL).respond(json=ckan([*bs_records, {"שם מכרז": "missing columns"}]))
+ respx_mock.get(GOV_URL).respond(text=GOV_EMPTY_PAGE)
with Michraz(cache_dir=None) as m:
tenders = m.search()
assert len(tenders) == len(rmi_records) + len(bs_records)
@@ -67,3 +71,44 @@ def test_one_bad_record_does_not_kill_the_batch(respx_mock, rmi_records, bs_reco
def test_module_entry_point_runs():
done = subprocess.run([sys.executable, "-m", "michraz", "--version"], capture_output=True, text=True, check=True)
assert done.stdout.startswith("michraz ")
+
+
+@pytest.fixture
+def all_services(respx_mock, rmi_records, bs_records):
+ respx_mock.post(SEARCH_URL).respond(json=rmi_records)
+ respx_mock.get(API_URL).respond(json=ckan(bs_records))
+ respx_mock.get(GOV_URL).respond(text=(FIXTURES / "gov_search.html").read_text(encoding="utf-8"))
+
+
+def test_every_default_source_contributes_and_exports(all_services, tmp_path):
+ with Michraz(cache_dir=None) as m:
+ tenders = m.search()
+ assert m.last_errors == []
+ assert {t.source for t in tenders} == {"rmi", "beersheva", "gov"}
+
+ gov = [t for t in tenders if t.source == "gov"]
+ assert len(gov) == 5
+ assert all(t.url.startswith("https://mr.gov.il/") and t.publisher and t.deadline for t in gov)
+
+ path = tmp_path / "all.csv"
+ to_csv(tenders, path)
+ with open(path, encoding="utf-8-sig", newline="") as handle:
+ rows = list(csv.DictReader(handle))
+ assert {r["source"] for r in rows} == {"rmi", "beersheva", "gov"}
+ assert all("mr.gov.il" in r["attribution"] for r in rows if r["source"] == "gov")
+
+
+def test_gov_keyword_and_window_filters(all_services):
+ with Michraz(cache_dir=None) as m:
+ hits = m.search(keyword="מנתחים", source="gov")
+ window = m.search(source="gov", closing_after=date(2026, 12, 1), closing_before=date(2026, 12, 31))
+ assert [t.source_id for t in hits] == ["4000623708"]
+ assert window and all(date(2026, 12, 1) <= t.deadline.date() < date(2026, 12, 31) for t in window)
+
+
+def test_cli_prints_government_tenders_as_json(all_services, tmp_path, monkeypatch, capsys):
+ monkeypatch.setattr("michraz.cli.DEFAULT_CACHE_DIR", tmp_path / "cache")
+ assert main(["search", "--source", "gov", "--json"]) == 0
+ out = json.loads(capsys.readouterr().out)
+ assert len(out) == 5 and {t["source"] for t in out} == {"gov"}
+ assert all("mr.gov.il" in t["attribution"] for t in out)
diff --git a/tests/test_enums.py b/tests/test_enums.py
index 58c8adf..5840eb3 100644
--- a/tests/test_enums.py
+++ b/tests/test_enums.py
@@ -9,8 +9,9 @@
from michraz.export import CSV_COLUMNS, CsvColumn
from michraz.models import RAW_EXTRA_KEY
from michraz.sources.beersheva import API_URL
+from michraz.sources.gov import SEARCH_URL as GOV_URL
from michraz.sources.rmi import SEARCH_URL
-from tests.helpers import ckan
+from tests.helpers import GOV_EMPTY_PAGE, ckan
class Sample(StrEnum):
@@ -26,7 +27,7 @@ def test_str_enum_behaves_like_its_value():
def test_source_names_match_the_sources_and_the_cli():
assert RmiSource.name == SourceName.RMI and BeerShevaSource.name == SourceName.BEERSHEVA
- assert [s.value for s in SourceName] == ["rmi", "beersheva"]
+ assert [s.value for s in SourceName] == ["rmi", "beersheva", "gov"]
assert RmiSource.name == "rmi" and type(RmiSource.name) is str # plain str, so custom sources need no enum
@@ -69,6 +70,7 @@ def test_exit_codes_and_commands():
def test_enum_keys_work_end_to_end_through_the_cli(respx_mock, rmi_records, bs_records, capsys):
respx_mock.post(SEARCH_URL).respond(json=rmi_records)
respx_mock.get(API_URL).respond(json=ckan(bs_records))
+ respx_mock.get(GOV_URL).respond(text=GOV_EMPTY_PAGE)
for name in SourceName:
assert main([Command.SEARCH, "--no-cache", "--source", name]) == ExitCode.OK
assert "rmi" in capsys.readouterr().out
diff --git a/tests/test_gov.py b/tests/test_gov.py
new file mode 100644
index 0000000..b09c4ac
--- /dev/null
+++ b/tests/test_gov.py
@@ -0,0 +1,106 @@
+from datetime import timedelta
+from urllib.parse import parse_qs
+
+import pytest
+
+from michraz import GovSource, SourceFormatError, SourceUnavailableError
+from michraz.sources.gov import SEARCH_URL, GovExtra, parse_page
+from tests.helpers import FIXTURES
+
+
+@pytest.fixture
+def page() -> str:
+ return (FIXTURES / "gov_search.html").read_text(encoding="utf-8")
+
+
+@pytest.fixture
+def source(http):
+ src = GovSource()
+ src.bind(http)
+ return src
+
+
+@pytest.fixture
+def by_id(respx_mock, source, page):
+ respx_mock.get(SEARCH_URL).respond(text=page)
+ return {t.source_id: t for t in source.fetch()}
+
+
+def test_parse_page_reads_every_result_and_the_total(page):
+ results, total = parse_page(page)
+ assert total == 5
+ assert [r["id"] for r in results][:2] == ["4000623708", "4000621860"]
+ assert results[0]["type"] == "מכרז פומבי"
+
+
+def test_parse_page_rejects_a_page_without_a_count():
+ with pytest.raises(ValueError, match="result count"):
+ parse_page("maintenance")
+
+
+def test_basic_fields(by_id):
+ t = by_id["4000623708"]
+ assert (t.source, t.number) == ("gov", "10003782")
+ assert t.publisher == 'משרד הבריאות - המרכז הרפואי ע"ש חיים שיבא, תל השומר'
+ assert t.title.startswith("מכרז פומבי לחלוקי מנתחים")
+ assert t.url == "https://mr.gov.il/ilgstorefront/he/p/4000623708"
+ assert "mr.gov.il" in t.attribution
+ assert t.extra[GovExtra.TYPE] == "מכרז פומבי"
+ assert t.extra["status"] == "עודכן"
+
+
+def test_dates_are_jerusalem_local_time(by_id):
+ t = by_id["4000623708"]
+ assert (t.deadline.month, t.deadline.day, t.deadline.hour) == (12, 28, 11)
+ assert t.deadline.utcoffset() == timedelta(hours=2)
+ assert (t.published_at.day, t.updated_at.day) == (3, 5)
+
+
+def test_a_tender_listed_under_two_statuses_is_returned_once(by_id):
+ assert len(by_id) == 5
+
+
+def test_requests_each_open_status_with_the_archive_hidden(respx_mock, source, page):
+ route = respx_mock.get(SEARCH_URL).respond(text=page)
+ list(source.fetch())
+ queries = [parse_qs(call.request.url.query.decode())["q"][0] for call in route.calls]
+ assert [q.rsplit(":", 1)[1] for q in queries] == ["פורסם", "עודכן", "חדש"]
+ assert all(q.startswith(":updateDate:archive:false:status:") for q in queries)
+
+
+def test_reads_pages_until_the_total_is_reached(respx_mock, source, page):
+ first = page.replace("נמצאו 5 תוצאות", "נמצאו 10 תוצאות")
+ route = respx_mock.get(SEARCH_URL).respond(text=first)
+ list(source.fetch())
+ pages = [parse_qs(call.request.url.query.decode())["page"][0] for call in route.calls]
+ assert pages[:3] == ["0", "1", "0"]
+
+
+def test_a_record_without_a_title_is_skipped_and_reported(respx_mock, source, page):
+ broken = page.replace("search-results-content-head", "search-results-other-class", 1)
+ respx_mock.get(SEARCH_URL).respond(text=broken)
+ tenders = list(source.fetch())
+ assert len(tenders) == 4
+ assert len(set(source.errors)) == 1 and "4000623708" in source.errors[0]
+
+
+def test_a_missing_deadline_gives_none(source):
+ tender = source.parse({"id": "1", "title": "t", "type": None, "fields": {}})
+ assert tender.deadline is None and not tender.is_open()
+
+
+def test_a_bad_date_raises(source):
+ with pytest.raises(ValueError):
+ source.parse({"id": "1", "title": "t", "type": None, "fields": {"מועד אחרון להגשה": "soon"}})
+
+
+def test_non_search_page_raises_format_error(respx_mock, source):
+ respx_mock.get(SEARCH_URL).respond(text="captcha")
+ with pytest.raises(SourceFormatError):
+ list(source.fetch())
+
+
+def test_server_error_raises_unavailable(respx_mock, source):
+ respx_mock.get(SEARCH_URL).respond(status_code=503)
+ with pytest.raises(SourceUnavailableError):
+ list(source.fetch())