diff --git a/CHANGELOG.md b/CHANGELOG.md index 714f00e..d614e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes are listed here. Format based on [Keep a Changelog](https://keepachangelog.com/). +## Unreleased + +### Added +- `whole_word` option for `Michraz.search` and `--whole-word` for the CLI: match the keyword as a whole word, so `נוי` + does not match `בינוי` or `פינוי`. Hebrew prefix letters (`ו ה כ ל מ ב ש`) are allowed in front of the word. + ## 0.2.0 (2026-11-05) ### Added diff --git a/README.md b/README.md index a3719cb..f308237 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ to_csv(tenders, "tenders.csv") ``` Results are sorted by deadline. Filter by `source`, `closing_before` and `closing_after` (dates or aware -datetimes) too. Each tender has an `attribution` string. Keep it when you republish the data. +datetimes) too. Keyword search matches text inside words. Pass `whole_word=True` (or `--whole-word`) to match whole words only, so +`נוי` finds `גינון ונוי` but not `בינוי`. Hebrew prefix letters like `ה` and `ל` are allowed in front of the word. +Each tender has an `attribution` string. Keep it when you republish the data. Some city sites list staff vacancies next to their tenders. They are hidden by default. Pass `kind=None` to see both, or `--kind all` on the command line. diff --git a/docs/images/pr-whole-word.png b/docs/images/pr-whole-word.png new file mode 100644 index 0000000..d475b84 Binary files /dev/null and b/docs/images/pr-whole-word.png differ diff --git a/src/michraz/cli.py b/src/michraz/cli.py index 7e0a754..86eec59 100644 --- a/src/michraz/cli.py +++ b/src/michraz/cli.py @@ -61,6 +61,9 @@ def _build_parser() -> argparse.ArgumentParser: search = commands.add_parser(Command.SEARCH.value, parents=[common], help="search tenders") search.add_argument("--keyword", "-k", help="text to look for in the title or number") + search.add_argument( + "--whole-word", action="store_true", help="match the keyword as a whole word, not as part of one" + ) search.add_argument("--open", action="store_true", help="only tenders that can still receive submissions") search.add_argument("--source", choices=[source.name for source in BUILT_IN], help="search a single source") search.add_argument( @@ -117,7 +120,9 @@ def line(cells: Sequence[str], title: str) -> str: def _search(args: argparse.Namespace, m: Michraz) -> int: kind = None if args.kind == ALL_KINDS else TenderKind(args.kind) - tenders = m.search(keyword=args.keyword, open_only=args.open, source=args.source, kind=kind) + tenders = m.search( + keyword=args.keyword, whole_word=args.whole_word, open_only=args.open, source=args.source, kind=kind + ) if args.csv: count = to_csv(tenders, args.csv) print(f"wrote {count} tenders to {args.csv}", file=sys.stderr) diff --git a/src/michraz/client.py b/src/michraz/client.py index c30f1a8..8495703 100644 --- a/src/michraz/client.py +++ b/src/michraz/client.py @@ -66,6 +66,7 @@ def search( self, keyword: str | None = None, open_only: bool = False, + whole_word: bool = False, source: str | None = None, closing_before: datetime | date | None = None, closing_after: datetime | date | None = None, @@ -75,6 +76,8 @@ def search( Args: keyword: Keep tenders whose title or number contains this text (case-insensitive, NFKC). + whole_word: Match ``keyword`` as a whole word, so ``נוי`` does not match ``בינוי``. Hebrew prefix letters + such as ``ה`` and ``ל`` are allowed in front of the word. open_only: Keep only tenders that can still receive submissions. source: Search only the source with this name. closing_before: Keep tenders whose deadline is before this moment. A date means the start of @@ -94,6 +97,7 @@ def search( """ criteria = TenderFilter( keyword=keyword, + whole_word=whole_word, open_only=open_only, closing_before=closing_before, closing_after=closing_after, diff --git a/src/michraz/filters.py b/src/michraz/filters.py index be79ee5..67037dd 100644 --- a/src/michraz/filters.py +++ b/src/michraz/filters.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import unicodedata from collections.abc import Iterable from dataclasses import dataclass @@ -12,6 +13,9 @@ from michraz.models import Tender, TenderKind NORMALIZATION_FORM: Final = "NFKC" +HEBREW_PREFIXES: Final = "והכלמבש" +"""One-letter words that attach to the front of a Hebrew word: and, the, as, to, from, in, that.""" +MAX_PREFIX_LETTERS: Final = 3 _EARLIEST = datetime.min.replace(tzinfo=_dates.JERUSALEM) @@ -21,14 +25,23 @@ def normalize(text: str) -> str: return unicodedata.normalize(NORMALIZATION_FORM, text).casefold() -def matches_keyword(tender: Tender, keyword: str) -> bool: +def matches_keyword(tender: Tender, keyword: str, whole_word: bool = False) -> bool: """Tells whether ``keyword`` occurs in the title or the number of ``tender``. - The match is a case-insensitive substring match on NFKC-normalized text. There is no stemming. + The match is case-insensitive on NFKC-normalized text. There is no stemming. + + Args: + tender: The tender to look at. + keyword: Text to look for. + whole_word: Match whole words only. A Hebrew word may carry up to three prefix letters (``ו ה כ ל מ ב ש``), + so ``פרחים`` also matches ``הפרחים`` and ``לפרחים`` but ``נוי`` does not match ``בינוי`` or ``פינוי``. """ needle = normalize(keyword.strip()) haystack = normalize(f"{tender.title}\n{tender.number or ''}") - return needle in haystack + if not whole_word: + return needle in haystack + prefix = f"[{HEBREW_PREFIXES}]{{0,{MAX_PREFIX_LETTERS}}}" + return re.search(rf"(? datetime | None: @@ -47,6 +60,7 @@ class TenderFilter: Attributes: keyword: Keep tenders whose title or number contains this text. + whole_word: Match ``keyword`` as a whole word. See :func:`matches_keyword`. open_only: Keep tenders for which :meth:`Tender.is_open` is true. closing_before: Keep tenders whose deadline is strictly before this moment. A plain date means 00:00 at the start of that day in Asia/Jerusalem. @@ -59,6 +73,7 @@ class TenderFilter: """ keyword: str | None = None + whole_word: bool = False open_only: bool = False closing_before: datetime | date | None = None closing_after: datetime | date | None = None @@ -75,7 +90,7 @@ def matches(self, tender: Tender) -> bool: """Tells whether ``tender`` meets every condition.""" if self.kind is not None and tender.kind != self.kind: return False - if self.keyword and not matches_keyword(tender, self.keyword): + if self.keyword and not matches_keyword(tender, self.keyword, self.whole_word): return False if self.open_only and not tender.is_open(self.now): return False @@ -99,6 +114,7 @@ def apply_filters( tenders: Iterable[Tender], *, keyword: str | None = None, + whole_word: bool = False, open_only: bool = False, closing_before: datetime | date | None = None, closing_after: datetime | date | None = None, @@ -108,6 +124,7 @@ def apply_filters( """Filters tenders. Shorthand for ``TenderFilter(...).apply(tenders)``; see :class:`TenderFilter`.""" return TenderFilter( keyword=keyword, + whole_word=whole_word, open_only=open_only, closing_before=closing_before, closing_after=closing_after, diff --git a/tests/test_cli.py b/tests/test_cli.py index 79289e2..4db1be9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -110,3 +110,9 @@ def test_a_command_is_required(capsys): with pytest.raises(SystemExit) as exit_info: main([]) assert exit_info.value.code == 2 + + +def test_whole_word_option(served, capsys): + base = ("search", "--no-cache", "--source", "beersheva", "--keyword", "גינו") + assert "1 tender\n" in run(capsys, *base)[1] # part of "גינון" + assert "0 tenders\n" in run(capsys, *base, "--whole-word")[1] diff --git a/tests/test_filters.py b/tests/test_filters.py index 7fca1c4..4cf2125 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -115,3 +115,29 @@ def test_kind_filter_keeps_only_that_kind(): assert apply_filters([tender, vacancy], kind=TenderKind.TENDER) == [tender] assert apply_filters([tender, vacancy], kind=TenderKind.VACANCY) == [vacancy] assert apply_filters([tender, vacancy]) == [tender, vacancy] + + +@pytest.mark.parametrize( + ("title", "keyword", "expected"), + [ + ("מכרז לפרחים", "פרחים", True), # to the flowers + ("רובע הפרחים", "פרחים", True), # the flowers + ("הפרחים והצמחים", "צמחים", True), # and-the plants + ("בינוי ופיתוח", "נוי", False), + ("פינוי פסולת", "נוי", False), + ("נוירו", "נוי", False), + ("מאגר היועצים", "עצים", False), + ("גיזום עצים", "עצים", True), + ("Gardening services", "garden", False), + ("Garden services", "GARDEN", True), + ("שירותי גינון ונוי", "נוי", True), + ("מכרז 12/2026", "12/2026", True), + ], +) +def test_whole_word_keyword(title, keyword, expected): + assert matches_keyword(make_tender(title=title), keyword, whole_word=True) is expected + + +def test_substring_match_is_still_the_default(): + assert matches_keyword(make_tender(title="בינוי ופיתוח"), "נוי") + assert apply_filters([make_tender(title="בינוי")], keyword="נוי", whole_word=True) == []