From 9d7851e41aa191cfc4be94c732d4cf3ce851000c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Thu, 2 Oct 2025 20:13:22 +0000 Subject: [PATCH 01/10] feat: add node by filter function --- src/python_picnic_api2/client.py | 6 ++++-- src/python_picnic_api2/helper.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/python_picnic_api2/client.py b/src/python_picnic_api2/client.py index 9e37c21..b462683 100644 --- a/src/python_picnic_api2/client.py +++ b/src/python_picnic_api2/client.py @@ -114,8 +114,10 @@ def get_article(self, article_id: str, add_category_name=False): return None color_regex = re.compile(r"#\(#\d{6}\)") - producer = re.sub(color_regex, "", str(article_details[1].get("markdown", ""))) - article_name = re.sub(color_regex, "", str(article_details[0]["markdown"])) + producer = re.sub(color_regex, "", str( + article_details[1].get("markdown", ""))) + article_name = re.sub(color_regex, "", str( + article_details[0]["markdown"])) article = {"name": f"{producer} {article_name}", "id": article_id} diff --git a/src/python_picnic_api2/helper.py b/src/python_picnic_api2/helper.py index 9bd08cb..eb812a6 100644 --- a/src/python_picnic_api2/helper.py +++ b/src/python_picnic_api2/helper.py @@ -85,6 +85,28 @@ def get_image(id: str, size="regular", suffix="webp"): return f"{IMAGE_BASE_URL}/{id}/{size}.{suffix}" +def find_node_by_content(node, filter): + def is_dict_included(node_dict, filter_dict): + for k, v in filter_dict.items(): + if k not in node_dict: + return False + if isinstance(v, dict) and isinstance(node_dict[k], dict): + if not is_dict_included(node_dict[k], v): + return False + elif node_dict[k] != v: + return False + return True + + if is_dict_included(node, filter): + return node + + for child in node.get("children", []): + find_node_by_content(child, filter) + + if "child" in node: + find_node_by_content(node.get("child"), filter) + + def _extract_search_results(raw_results, max_items: int = 10): """Extract search results from the nested dictionary structure returned by Picnic search. Number of max items can be defined to reduce excessive nested @@ -100,7 +122,8 @@ def find_articles(node): content = node.get("content", {}) if content.get("type") == "SELLING_UNIT_TILE" and "sellingUnit" in content: selling_unit = content["sellingUnit"] - sole_article_ids = SOLE_ARTICLE_ID_PATTERN.findall(json.dumps(node)) + sole_article_ids = SOLE_ARTICLE_ID_PATTERN.findall( + json.dumps(node)) sole_article_id = sole_article_ids[0] if sole_article_ids else None result_entry = { **selling_unit, @@ -118,6 +141,7 @@ def find_articles(node): body = raw_results.get("body", {}) find_articles(body.get("child", {})) - LOGGER.debug(f"Found {len(search_results)}/{max_items} products after extraction") + LOGGER.debug( + f"Found {len(search_results)}/{max_items} products after extraction") return [{"items": search_results}] From afb65eb44a8f84a6900d123c570f4faf971c1b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 11:42:50 +0000 Subject: [PATCH 02/10] feat: migrate product search to generic node extractor --- src/python_picnic_api2/helper.py | 67 +++++++++++++++++--------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/src/python_picnic_api2/helper.py b/src/python_picnic_api2/helper.py index eb812a6..a183e76 100644 --- a/src/python_picnic_api2/helper.py +++ b/src/python_picnic_api2/helper.py @@ -85,7 +85,12 @@ def get_image(id: str, size="regular", suffix="webp"): return f"{IMAGE_BASE_URL}/{id}/{size}.{suffix}" -def find_node_by_content(node, filter): +def find_nodes_by_content(node, filter, max_nodes: int = 10): + nodes = [] + + if len(nodes) >= 10: + return nodes + def is_dict_included(node_dict, filter_dict): for k, v in filter_dict.items(): if k not in node_dict: @@ -93,53 +98,51 @@ def is_dict_included(node_dict, filter_dict): if isinstance(v, dict) and isinstance(node_dict[k], dict): if not is_dict_included(node_dict[k], v): return False - elif node_dict[k] != v: + elif node_dict[k] != v and v is not None: return False return True if is_dict_included(node, filter): - return node + nodes.append(node) - for child in node.get("children", []): - find_node_by_content(child, filter) + if isinstance(node, dict): + for _, v in node.items(): + if isinstance(v, dict): + nodes.extend(find_nodes_by_content(v, filter, max_nodes)) + continue + if isinstance(v, list): + for item in v: + if isinstance(v, (dict, list)): + nodes.extend(find_nodes_by_content( + item, filter, max_nodes)) + continue - if "child" in node: - find_node_by_content(node.get("child"), filter) + return nodes def _extract_search_results(raw_results, max_items: int = 10): """Extract search results from the nested dictionary structure returned by Picnic search. Number of max items can be defined to reduce excessive nested search""" - search_results = [] LOGGER.debug(f"Extracting search results from {raw_results}") - def find_articles(node): - if len(search_results) >= max_items: - return - - content = node.get("content", {}) - if content.get("type") == "SELLING_UNIT_TILE" and "sellingUnit" in content: - selling_unit = content["sellingUnit"] - sole_article_ids = SOLE_ARTICLE_ID_PATTERN.findall( - json.dumps(node)) - sole_article_id = sole_article_ids[0] if sole_article_ids else None - result_entry = { - **selling_unit, - "sole_article_id": sole_article_id, - } - LOGGER.debug(f"Found article {result_entry}") - search_results.append(result_entry) - - for child in node.get("children", []): - find_articles(child) - - if "child" in node: - find_articles(node.get("child")) - body = raw_results.get("body", {}) - find_articles(body.get("child", {})) + nodes = find_nodes_by_content(body.get("child", {}), { + "type": "SELLING_UNIT_TILE", "sellingUnit": {}}) + + search_results = [] + for node in nodes: + selling_unit = node["sellingUnit"] + sole_article_ids = SOLE_ARTICLE_ID_PATTERN.findall( + json.dumps(node)) + sole_article_id = sole_article_ids[0] if sole_article_ids else None + result_entry = { + **selling_unit, + "sole_article_id": sole_article_id, + } + LOGGER.debug(f"Found article {result_entry}") + search_results.append(result_entry) LOGGER.debug( f"Found {len(search_results)}/{max_items} products after extraction") From eeed070767ce078ea49118545204cc5e89614d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 11:47:51 +0000 Subject: [PATCH 03/10] fix: comply with UP038 --- src/python_picnic_api2/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python_picnic_api2/helper.py b/src/python_picnic_api2/helper.py index a183e76..8a96518 100644 --- a/src/python_picnic_api2/helper.py +++ b/src/python_picnic_api2/helper.py @@ -112,7 +112,7 @@ def is_dict_included(node_dict, filter_dict): continue if isinstance(v, list): for item in v: - if isinstance(v, (dict, list)): + if isinstance(v, dict | list): nodes.extend(find_nodes_by_content( item, filter, max_nodes)) continue From ae8b869dda9214872b47b6740e7119fd6eb4f3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 12:47:19 +0000 Subject: [PATCH 04/10] feat: function to get category details --- src/python_picnic_api2/client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/python_picnic_api2/client.py b/src/python_picnic_api2/client.py index b462683..a71d6df 100644 --- a/src/python_picnic_api2/client.py +++ b/src/python_picnic_api2/client.py @@ -8,6 +8,7 @@ _extract_search_results, _tree_generator, _url_generator, + find_nodes_by_content, ) from .session import PicnicAPISession, PicnicAuthError @@ -172,6 +173,17 @@ def get_current_deliveries(self): def get_categories(self, depth: int = 0): return self._get(f"/my_store?depth={depth}")["catalog"] + def get_category_by_ids(self, l2_id: int, l3_id: int): + path = "/pages/L2-category-page-root" + \ + f"?category_id={l2_id}&l3_category_id={l3_id}" + data = self._get(path, add_picnic_headers=True) + nodes = find_nodes_by_content( + data, {"id": f"vertical-article-tiles-sub-header-{l3_id}"}, max_nodes=1) + if len(nodes) == 0: + raise KeyError("Could not find category with specified IDs") + return {"l2_id": l2_id, "l3_id": l3_id, + "name": nodes[0]["pml"]["component"]["accessibilityLabel"]} + def print_categories(self, depth: int = 0): tree = "\n".join(_tree_generator(self.get_categories(depth=depth))) print(tree) From 0c0b13abad9b214275e74dd041d22c3d86aa7d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 12:57:57 +0000 Subject: [PATCH 05/10] feat: return category information with article details --- src/python_picnic_api2/client.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/python_picnic_api2/client.py b/src/python_picnic_api2/client.py index a71d6df..e3a4f4d 100644 --- a/src/python_picnic_api2/client.py +++ b/src/python_picnic_api2/client.py @@ -102,9 +102,8 @@ def get_cart(self): return self._get("/cart") def get_article(self, article_id: str, add_category_name=False): - if add_category_name: - raise NotImplementedError() - path = f"/pages/product-details-page-root?id={article_id}" + path = f"/pages/product-details-page-root?id={article_id}" + \ + "&show_category_action=true" data = self._get(path, add_picnic_headers=True) article_details = [] for block in data["body"]["child"]["child"]["children"]: @@ -114,13 +113,28 @@ def get_article(self, article_id: str, add_category_name=False): if len(article_details) == 0: return None + article = {} + if add_category_name: + cat_node = find_nodes_by_content( + data, {"id": "category-button"}, max_nodes=1) + if len(cat_node) == 0: + raise KeyError( + f"Could not extract category from article with id {article_id}") + category_regex = re.compile( + "app\\.picnic:\\/\\/categories\\/(\\d+)\\/l2\\/(\\d+)\\/l3\\/(\\d+)") + cat_ids = category_regex.match( + cat_node[0]["pml"]["component"]["onPress"]["target"]).groups() + article["category"] = self.get_category_by_ids( + int(cat_ids[1]), int(cat_ids[2])) + color_regex = re.compile(r"#\(#\d{6}\)") producer = re.sub(color_regex, "", str( article_details[1].get("markdown", ""))) article_name = re.sub(color_regex, "", str( article_details[0]["markdown"])) - article = {"name": f"{producer} {article_name}", "id": article_id} + article["name"] = f"{producer} {article_name}" + article["id"] = article_id return article From f466621d4b7f6b1f078050c498490dbf8d0859ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 12:59:50 +0000 Subject: [PATCH 06/10] fix: rename parameter for adding category information --- integration_tests/test_client.py | 5 +++-- src/python_picnic_api2/client.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/integration_tests/test_client.py b/integration_tests/test_client.py index ffd2f29..b71ab5d 100644 --- a/integration_tests/test_client.py +++ b/integration_tests/test_client.py @@ -53,7 +53,7 @@ def test_get_article(): def test_get_article_with_category_name(): with pytest.raises(NotImplementedError): - picnic.get_article("s1018620", add_category_name=True) + picnic.get_article("s1018620", add_category=True) def test_get_article_by_gtin(): @@ -81,7 +81,8 @@ def test_add_product(): assert isinstance(response, dict) assert "items" in response - assert any(item["id"] == "s1018620" for item in response["items"][0]["items"]) + assert any( + item["id"] == "s1018620" for item in response["items"][0]["items"]) assert _get_amount(response, "s1018620") == 2 diff --git a/src/python_picnic_api2/client.py b/src/python_picnic_api2/client.py index e3a4f4d..fd61ee7 100644 --- a/src/python_picnic_api2/client.py +++ b/src/python_picnic_api2/client.py @@ -101,7 +101,7 @@ def search(self, term: str): def get_cart(self): return self._get("/cart") - def get_article(self, article_id: str, add_category_name=False): + def get_article(self, article_id: str, add_category=False): path = f"/pages/product-details-page-root?id={article_id}" + \ "&show_category_action=true" data = self._get(path, add_picnic_headers=True) @@ -114,7 +114,7 @@ def get_article(self, article_id: str, add_category_name=False): return None article = {} - if add_category_name: + if add_category: cat_node = find_nodes_by_content( data, {"id": "category-button"}, max_nodes=1) if len(cat_node) == 0: From ee00ad11178bcf8a87d4c3643488df36489fc9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 13:25:51 +0000 Subject: [PATCH 07/10] test: add test for get_category_by_ids --- tests/test_client.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index fdd4871..773addb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -110,7 +110,7 @@ def test_search_encoding(self): def test_get_article(self): self.client.get_article("p3f2qa") self.session_mock().get.assert_called_with( - "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa", + "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true", headers=PICNIC_HEADERS, ) @@ -220,6 +220,30 @@ def test_get_categories(self): {"type": "CATEGORY", "id": "purchases", "name": "Besteld"}, ) + def test_get_category_by_ids(self): + self.session_mock().get.return_value = self.MockResponse( + {"children": [ + { + "id": "vertical-article-tiles-sub-header-22193", + "pml": { + "component": { + "accessibilityLabel": "Halvarine" + } + } + } + ]}, + 200 + ) + + category = self.client.get_category_by_ids(1000, 22193) + self.session_mock().get.assert_called_with( + f"{self.expected_base_url}/pages/L2-category-page-root" + + "?category_id=1000&l3_category_id=22193", headers=PICNIC_HEADERS + ) + + self.assertDictEqual( + category, {"name": "Halvarine", "l2_id": 1000, "l3_id": 22193}) + def test_get_auth_exception(self): self.session_mock().get.return_value = self.MockResponse( {"error": {"code": "AUTH_ERROR"}}, 400 From 158dae824233ca4f5cc05e69ed68cf9eb1e43faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 14:23:04 +0000 Subject: [PATCH 08/10] test: improve tests for get_article --- tests/test_client.py | 65 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 773addb..55ba9e3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -108,12 +108,75 @@ def test_search_encoding(self): ) def test_get_article(self): - self.client.get_article("p3f2qa") + self.session_mock().get.return_value = self.MockResponse( + {"body": {"child": {"child": {"children": [{ + "id": "product-details-page-root-main-container", + "pml": { + "component": { + "children": [ + { + "markdown": "#(#333333)Goede start halvarine#(#333333)", + }, + { + "markdown": "Blue Band", + }, + + ] + } + } + }]}}}}, + 200 + ) + + article = self.client.get_article("p3f2qa") + self.session_mock().get.assert_called_with( + "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true", + headers=PICNIC_HEADERS, + ) + + self.assertEqual( + article, {'name': 'Blue Band Goede start halvarine', 'id': 'p3f2qa'}) + + def test_get_article_with_category(self): + self.session_mock().get.return_value = self.MockResponse( + {"body": {"child": {"child": {"children": [{ + "id": "product-details-page-root-main-container", + "pml": { + "component": { + "children": [ + { + "markdown": "#(#333333)Goede start halvarine#(#333333)", + }, + { + "markdown": "Blue Band", + }, + + ] + } + } + }, + { + "id": "category-button", + "pml": {"component": {"onPress": {"target": "app.picnic://categories/1000/l2/2000/l3/3000"}}} + }]}}}}, + 200 + ) + + category_patch = patch( + "python_picnic_api2.client.PicnicAPI.get_category_by_ids").start() + category_patch.return_value = { + "l2_id": 2000, "l3_id": 3000, "name": "Test"} + + article = self.client.get_article("p3f2qa", True) self.session_mock().get.assert_called_with( "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true", headers=PICNIC_HEADERS, ) + self.assertEqual( + article, {'name': 'Blue Band Goede start halvarine', 'id': 'p3f2qa', + "category": {"l2_id": 2000, "l3_id": 3000, "name": "Test"}}) + def test_get_article_by_gtin(self): self.client.get_article_by_gtin("123456789") self.session_mock().get.assert_called_with( From e6ef50c7af923cd5f961eb91f307fb82680bd1fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 14:26:02 +0000 Subject: [PATCH 09/10] test: stop patch for category after test --- tests/test_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 55ba9e3..1b638d7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -163,11 +163,13 @@ def test_get_article_with_category(self): ) category_patch = patch( - "python_picnic_api2.client.PicnicAPI.get_category_by_ids").start() - category_patch.return_value = { + "python_picnic_api2.client.PicnicAPI.get_category_by_ids") + category_patch.start().return_value = { "l2_id": 2000, "l3_id": 3000, "name": "Test"} article = self.client.get_article("p3f2qa", True) + + category_patch.stop() self.session_mock().get.assert_called_with( "https://storefront-prod.nl.picnicinternational.com/api/15/pages/product-details-page-root?id=p3f2qa&show_category_action=true", headers=PICNIC_HEADERS, From af442712c1ad5c4d29a639699355d706714ad83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Fri, 3 Oct 2025 14:33:46 +0000 Subject: [PATCH 10/10] test: re-enable category test for get_article --- integration_tests/test_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/integration_tests/test_client.py b/integration_tests/test_client.py index b71ab5d..b432d26 100644 --- a/integration_tests/test_client.py +++ b/integration_tests/test_client.py @@ -52,8 +52,10 @@ def test_get_article(): def test_get_article_with_category_name(): - with pytest.raises(NotImplementedError): - picnic.get_article("s1018620", add_category=True) + response = picnic.get_article("s1018620", add_category=True) + assert isinstance(response, dict) + assert "category" in response + assert response["category"]["name"] == "H-Milch" def test_get_article_by_gtin():