Skip to content

Commit 7aaec51

Browse files
committed
Fetch nested Notion page blocks
1 parent a05883a commit 7aaec51

3 files changed

Lines changed: 107 additions & 13 deletions

File tree

backend/app/connectors/notion.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DEFAULT_NOTION_VERSION = "2026-03-11"
1717
MAX_RECORDS = 200
1818
MAX_BLOCKS_PER_PAGE = 80
19+
MAX_BLOCK_TREE_DEPTH = 3
1920

2021

2122
RequestJSON = Callable[[str, dict[str, str], dict[str, Any] | None, str], Any]
@@ -172,26 +173,77 @@ def _fetch_page_blocks(
172173
if not page_id:
173174
return []
174175
blocks: list[dict[str, Any]] = []
176+
_append_child_blocks(
177+
base_url,
178+
headers,
179+
parent_id=page_id,
180+
requester=requester,
181+
errors=errors,
182+
secrets=secrets,
183+
blocks=blocks,
184+
depth=0,
185+
seen_parent_ids=set(),
186+
)
187+
return blocks
188+
189+
190+
def _append_child_blocks(
191+
base_url: str,
192+
headers: dict[str, str],
193+
*,
194+
parent_id: str,
195+
requester: RequestJSON,
196+
errors: list[dict[str, Any]],
197+
secrets: list[str | None],
198+
blocks: list[dict[str, Any]],
199+
depth: int,
200+
seen_parent_ids: set[str],
201+
) -> None:
202+
if len(blocks) >= MAX_BLOCKS_PER_PAGE or depth > MAX_BLOCK_TREE_DEPTH:
203+
return
204+
if parent_id in seen_parent_ids:
205+
errors.append({"block_id": parent_id, "error": "Notion block tree contained a repeated parent id"})
206+
return
207+
seen_parent_ids.add(parent_id)
175208
cursor: str | None = None
176209
while len(blocks) < MAX_BLOCKS_PER_PAGE:
177210
query = {"page_size": str(min(100, MAX_BLOCKS_PER_PAGE - len(blocks)))}
178211
if cursor:
179212
query["start_cursor"] = cursor
180-
url = f"{base_url}/blocks/{page_id}/children?{urlencode(query)}"
213+
url = f"{base_url}/blocks/{parent_id}/children?{urlencode(query)}"
181214
try:
182215
payload = requester(url, headers, None, "GET")
183216
except Exception as exc:
184-
errors.append({"page_id": page_id, "error": redact_error_message(exc, secrets)})
217+
errors.append({"block_id": parent_id, "error": redact_error_message(exc, secrets)})
185218
break
186219
if not isinstance(payload, dict):
187-
errors.append({"page_id": page_id, "error": "Notion block children response was not an object"})
220+
errors.append({"block_id": parent_id, "error": "Notion block children response was not an object"})
188221
break
189222
results = payload.get("results") if isinstance(payload.get("results"), list) else []
190-
blocks.extend(item for item in results if isinstance(item, dict))
223+
for item in results:
224+
if not isinstance(item, dict):
225+
continue
226+
block = {**item, "_cortex_depth": depth}
227+
blocks.append(block)
228+
block_id = _text(item.get("id"))
229+
if bool(item.get("has_children")) and block_id and len(blocks) < MAX_BLOCKS_PER_PAGE:
230+
_append_child_blocks(
231+
base_url,
232+
headers,
233+
parent_id=block_id,
234+
requester=requester,
235+
errors=errors,
236+
secrets=secrets,
237+
blocks=blocks,
238+
depth=depth + 1,
239+
seen_parent_ids=seen_parent_ids,
240+
)
241+
if len(blocks) >= MAX_BLOCKS_PER_PAGE:
242+
break
191243
cursor = str(payload.get("next_cursor") or "").strip() or None
192244
if not payload.get("has_more") or not cursor:
193245
break
194-
return blocks
246+
seen_parent_ids.discard(parent_id)
195247

196248

197249
def _record_from_page(page: dict[str, Any], blocks: list[dict[str, Any]]) -> NotionSyncRecord | None:
@@ -300,10 +352,20 @@ def _block_text(block: dict[str, Any]) -> str:
300352
text = _rich_text(value.get("rich_text") if isinstance(value, dict) else None)
301353
if not text and block_type == "child_page" and isinstance(value, dict):
302354
text = _clean_text(value.get("title"))
355+
if not text and block_type == "child_database" and isinstance(value, dict):
356+
text = _clean_text(value.get("title"))
357+
if not text and block_type in {"bookmark", "embed", "link_preview"} and isinstance(value, dict):
358+
text = _clean_text(value.get("url"))
303359
if not text:
304360
return ""
305361
label = block_type.replace("_", " ").title()
306-
return f"{label}: {text}"
362+
depth = 0
363+
try:
364+
depth = max(0, int(block.get("_cortex_depth") or 0))
365+
except (TypeError, ValueError):
366+
depth = 0
367+
prefix = " " * min(depth, MAX_BLOCK_TREE_DEPTH)
368+
return f"{prefix}{label}: {text}"
307369

308370

309371
def _rich_text(value: Any) -> str:

backend/tests/test_connector_fetch_retrieval.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,16 +571,30 @@ def fake_request(url: str, headers: dict[str, str], body: dict | None, method: s
571571
}
572572
],
573573
}
574+
if "/blocks/page-1/children" in url:
575+
return {
576+
"has_more": False,
577+
"next_cursor": None,
578+
"results": [
579+
{
580+
"id": "toggle-1",
581+
"type": "toggle",
582+
"has_children": True,
583+
"toggle": {"rich_text": [{"plain_text": "Nested Cortex memory"}]},
584+
}
585+
],
586+
}
574587
return {
575588
"has_more": False,
576589
"next_cursor": None,
577590
"results": [
578591
{
592+
"id": "paragraph-1",
579593
"type": "paragraph",
580594
"paragraph": {
581595
"rich_text": [
582596
{
583-
"plain_text": "We decided notioncitetest retrieval should preserve Notion page citations.",
597+
"plain_text": "We decided notioncitetest retrieval should preserve nested Notion page citations.",
584598
}
585599
]
586600
},

backend/tests/test_notion_connector.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,32 @@ def fake_request(url: str, headers: dict[str, str], body: dict | None, method: s
3434
},
3535
}
3636
],
37-
}
37+
}
3838
self.assertEqual(method, "GET")
39-
self.assertIn("/blocks/page-1/children", url)
39+
if "/blocks/page-1/children" in url:
40+
return {
41+
"has_more": False,
42+
"next_cursor": None,
43+
"results": [
44+
{"id": "heading-1", "type": "heading_1", "heading_1": {"rich_text": [{"plain_text": "Memory loop"}]}},
45+
{
46+
"id": "toggle-1",
47+
"type": "toggle",
48+
"has_children": True,
49+
"toggle": {"rich_text": [{"plain_text": "Nested decisions"}]},
50+
},
51+
],
52+
}
53+
self.assertIn("/blocks/toggle-1/children", url)
4054
return {
4155
"has_more": False,
4256
"next_cursor": None,
4357
"results": [
44-
{"type": "heading_1", "heading_1": {"rich_text": [{"plain_text": "Memory loop"}]}},
45-
{"type": "paragraph", "paragraph": {"rich_text": [{"plain_text": "We decided Notion sync should cite page URLs."}]}},
58+
{
59+
"id": "paragraph-1",
60+
"type": "paragraph",
61+
"paragraph": {"rich_text": [{"plain_text": "We decided Notion sync should cite nested page content."}]},
62+
},
4663
],
4764
}
4865

@@ -52,7 +69,7 @@ def fake_request(url: str, headers: dict[str, str], body: dict | None, method: s
5269
request_json=fake_request,
5370
)
5471

55-
self.assertEqual(len(calls), 2)
72+
self.assertEqual(len(calls), 3)
5673
self.assertEqual(sync.records_found, 1)
5774
self.assertEqual(sync.records_returned, 1)
5875
self.assertEqual(sync.high_water_mark, "2026-06-30T10:00:00Z")
@@ -63,7 +80,8 @@ def fake_request(url: str, headers: dict[str, str], body: dict | None, method: s
6380
self.assertIn("Page: Project Atlas Plan", record["content"])
6481
self.assertIn("Properties: Status: In Progress; Tags: memory, backend", record["content"])
6582
self.assertIn("Heading 1: Memory loop", record["content"])
66-
self.assertIn("Paragraph: We decided Notion sync should cite page URLs.", record["content"])
83+
self.assertIn("Toggle: Nested decisions", record["content"])
84+
self.assertIn(" Paragraph: We decided Notion sync should cite nested page content.", record["content"])
6785
self.assertEqual(record["metadata"]["title"], "Project Atlas Plan")
6886

6987
def test_fetch_notion_records_requires_token(self) -> None:

0 commit comments

Comments
 (0)