33This module provides a unified interface for interacting with the Notion API
44"""
55
6+ import os
7+ import threading
68import time
9+ from collections .abc import Iterable
10+ from concurrent .futures import ThreadPoolExecutor
711from typing import Any
812
913import requests
1014
1115from dify_plugin .entities .datasource import OnlineDocumentPage
1216
1317__TIMEOUT_SECONDS__ = 60 * 10
18+ _DEFAULT_PARENT_RESOLVE_WORKERS = 8
19+ _MAX_PARENT_RESOLVE_WORKERS = 32
1420
1521
1622class NotionClient :
@@ -40,6 +46,10 @@ def __init__(self, integration_token: str):
4046 "Notion-Version" : self ._API_VERSION ,
4147 "Content-Type" : "application/json" ,
4248 }
49+ # Memoize parent_id lookups so the threaded resolution in
50+ # get_authorized_pages doesn't re-fetch the same ancestor block twice.
51+ self ._parent_cache : dict [str , str ] = {}
52+ self ._parent_cache_lock = threading .Lock ()
4353
4454 def _make_request (
4555 self ,
@@ -48,7 +58,8 @@ def _make_request(
4858 params : dict [str , Any ] | None = None ,
4959 json_data : dict [str , Any ] | None = None ,
5060 max_retries : int = 3 ,
51- ) -> dict [str , Any ]:
61+ allow_status : Iterable [int ] = (),
62+ ) -> dict [str , Any ] | None :
5263 """
5364 Make an API request to Notion with retry logic for rate limits.
5465
@@ -58,12 +69,15 @@ def _make_request(
5869 params: URL parameters for GET requests
5970 json_data: JSON data for POST/PATCH requests
6071 max_retries: Maximum number of retries for rate limiting
72+ allow_status: HTTP status codes that should resolve to None
73+ instead of raising (e.g. {404} for optional resources).
6174
6275 Returns:
63- Response data as dictionary
76+ Response data as dictionary, or None if the response status was in allow_status.
6477 """
6578 url = f"{ self ._API_BASE_URL } { endpoint } "
6679 retries = 0
80+ allow_status_set = set (allow_status )
6781
6882 while retries <= max_retries :
6983 try :
@@ -83,6 +97,9 @@ def _make_request(
8397 retries += 1
8498 continue
8599
100+ if response .status_code in allow_status_set :
101+ return None
102+
86103 response .raise_for_status ()
87104 return response .json ()
88105
@@ -439,145 +456,126 @@ def format_rich_text(self, content: str) -> list[dict[str, Any]]:
439456 """
440457 return self .create_rich_text (content )
441458
442- def get_authorized_pages (self ):
443- pages = []
444- access_token = self .integration_token
445- page_results = self .notion_page_search (access_token )
446- database_results = self .notion_database_search (access_token )
447- # get page detail
448- for page_result in page_results :
449- page_id = page_result ["id" ]
450- page_name = "Untitled"
451- for key in page_result ["properties" ]:
452- if "title" in page_result ["properties" ][key ] and page_result ["properties" ][key ]["title" ]:
453- title_list = page_result ["properties" ][key ]["title" ]
454- if len (title_list ) > 0 and "plain_text" in title_list [0 ]:
455- page_name = title_list [0 ]["plain_text" ]
456- icon = None
457- parent = page_result ["parent" ]
458- parent_type = parent ["type" ]
459- try :
460- if parent_type == "block_id" :
461- parent_id = self .notion_block_parent_page_id (access_token , parent [parent_type ])
462- elif parent_type == "workspace" :
463- parent_id = "root"
464- else :
465- parent_id = parent [parent_type ]
466- except ValueError :
467- continue
468- page = OnlineDocumentPage (
469- page_id = page_id ,
470- page_name = page_name ,
471- page_icon = icon ,
472- parent_id = parent_id ,
473- type = "page" ,
474- last_edited_time = page_result ["last_edited_time" ],
475- )
476- pages .append (page )
477- # get database detail
478- for database_result in database_results :
479- page_id = database_result ["id" ]
480- page_name = database_result ["title" ][0 ]["plain_text" ] if len (database_result ["title" ]) > 0 else "Untitled"
481-
482- icon = None
483- parent = database_result ["parent" ]
484- parent_type = parent ["type" ]
485- try :
486- if parent_type == "block_id" :
487- parent_id = self .notion_block_parent_page_id (access_token , parent [parent_type ])
488- elif parent_type == "workspace" :
489- parent_id = "root"
490- else :
491- parent_id = parent [parent_type ]
492- except ValueError :
493- continue
494- page = OnlineDocumentPage (
495- page_id = page_id ,
496- page_name = page_name ,
497- page_icon = icon ,
498- parent_id = parent_id ,
499- type = "database" ,
500- last_edited_time = database_result ["last_edited_time" ],
501- )
502- pages .append (page )
459+ def get_authorized_pages (self ) -> list [OnlineDocumentPage ]:
460+ items = self ._search_all ()
461+ worker_count = self ._resolve_worker_count ()
462+
463+ pages : list [OnlineDocumentPage ] = []
464+ if worker_count <= 1 :
465+ for item in items :
466+ entry = self ._build_page_entry (item )
467+ if entry is not None :
468+ pages .append (entry )
469+ return pages
470+
471+ with ThreadPoolExecutor (max_workers = worker_count ) as executor :
472+ for entry in executor .map (self ._build_page_entry , items ):
473+ if entry is not None :
474+ pages .append (entry )
503475 return pages
504476
505- def notion_page_search (self , access_token : str ):
506- results = []
507- next_cursor = None
508- has_more = True
477+ def _search_all (self ) -> list [dict [str , Any ]]:
478+ """Fetch every page and database shared with the integration in one pass.
509479
480+ Notion's /v1/search returns both pages and databases when no object filter
481+ is supplied, so combining the previous two filtered loops halves the number
482+ of search round-trips required to enumerate the workspace.
483+ """
484+ results : list [dict [str , Any ]] = []
485+ next_cursor : str | None = None
486+ has_more = True
510487 while has_more :
511- data : dict [str , Any ] = {
512- "filter" : {"value" : "page" , "property" : "object" },
513- ** ({"start_cursor" : next_cursor } if next_cursor else {}),
514- }
515- headers = {
516- "Content-Type" : "application/json" ,
517- "Authorization" : f"Bearer { access_token } " ,
518- "Notion-Version" : self ._API_VERSION ,
519- }
520-
521- response = requests .post (
522- url = self ._NOTION_PAGE_SEARCH , json = data , headers = headers , timeout = __TIMEOUT_SECONDS__
523- )
524- response_json = response .json ()
525-
526- results .extend (response_json .get ("results" , []))
527-
528- has_more = response_json .get ("has_more" , False )
529- next_cursor = response_json .get ("next_cursor" , None )
530-
488+ payload : dict [str , Any ] = {}
489+ if next_cursor :
490+ payload ["start_cursor" ] = next_cursor
491+ data = self ._make_request ("post" , "/search" , json_data = payload ) or {}
492+ results .extend (data .get ("results" , []))
493+ has_more = data .get ("has_more" , False )
494+ next_cursor = data .get ("next_cursor" )
531495 return results
532496
533- def notion_database_search (self , access_token : str ):
534- results = []
535- next_cursor = None
536- has_more = True
497+ def _build_page_entry (self , item : dict [str , Any ]) -> OnlineDocumentPage | None :
498+ obj = item .get ("object" )
499+ if obj == "page" :
500+ page_name = "Untitled"
501+ for key in item .get ("properties" , {}):
502+ prop = item ["properties" ][key ]
503+ if "title" in prop and prop ["title" ]:
504+ title_list = prop ["title" ]
505+ if len (title_list ) > 0 and "plain_text" in title_list [0 ]:
506+ page_name = title_list [0 ]["plain_text" ]
507+ page_type = "page"
508+ elif obj == "database" :
509+ title = item .get ("title" , [])
510+ page_name = title [0 ]["plain_text" ] if len (title ) > 0 else "Untitled"
511+ page_type = "database"
512+ else :
513+ return None
514+
515+ parent = item .get ("parent" , {})
516+ parent_type = parent .get ("type" )
517+ try :
518+ if parent_type == "block_id" :
519+ parent_id = self ._resolve_block_parent_page_id (parent [parent_type ])
520+ elif parent_type == "workspace" :
521+ parent_id = "root"
522+ elif parent_type and parent_type in parent :
523+ parent_id = parent [parent_type ]
524+ else :
525+ parent_id = "root"
526+ except ValueError :
527+ return None
528+
529+ return OnlineDocumentPage (
530+ page_id = item ["id" ],
531+ page_name = page_name ,
532+ page_icon = None ,
533+ parent_id = parent_id ,
534+ type = page_type ,
535+ last_edited_time = item ["last_edited_time" ],
536+ )
537537
538- while has_more :
539- data : dict [str , Any ] = {
540- "filter" : {"value" : "database" , "property" : "object" },
541- ** ({"start_cursor" : next_cursor } if next_cursor else {}),
542- }
543- headers = {
544- "Content-Type" : "application/json" ,
545- "Authorization" : f"Bearer { access_token } " ,
546- "Notion-Version" : self ._API_VERSION ,
547- }
548- response = requests .post (
549- url = self ._NOTION_PAGE_SEARCH , json = data , headers = headers , timeout = __TIMEOUT_SECONDS__
550- )
551- response_json = response .json ()
552-
553- results .extend (response_json .get ("results" , []))
554-
555- has_more = response_json .get ("has_more" , False )
556- next_cursor = response_json .get ("next_cursor" , None )
538+ @staticmethod
539+ def _resolve_worker_count () -> int :
540+ raw = os .environ .get ("NOTION_PARENT_RESOLVE_WORKERS" )
541+ if not raw :
542+ return _DEFAULT_PARENT_RESOLVE_WORKERS
543+ try :
544+ value = int (raw )
545+ except ValueError :
546+ return _DEFAULT_PARENT_RESOLVE_WORKERS
547+ return max (1 , min (value , _MAX_PARENT_RESOLVE_WORKERS ))
557548
558- return results
549+ def notion_page_search (self , access_token : str ):
550+ return [item for item in self ._search_all () if item .get ("object" ) == "page" ]
551+
552+ def notion_database_search (self , access_token : str ):
553+ return [item for item in self ._search_all () if item .get ("object" ) == "database" ]
559554
560555 def notion_block_parent_page_id (self , access_token : str , block_id : str ):
561- headers = {
562- "Authorization" : f"Bearer { access_token } " ,
563- "Notion-Version" : self ._API_VERSION ,
564- }
565- response = requests .get (
566- url = f"{ self ._NOTION_BLOCK_SEARCH } /{ block_id } " , headers = headers , timeout = __TIMEOUT_SECONDS__
567- )
568- if response .status_code == 404 :
569- # Treat inaccessible parent as a root-level item.
570- return "root"
571- if response .status_code != 200 :
572- try :
573- response_json = response .json ()
574- message = response_json .get ("message" , "unknown error" )
575- except requests .exceptions .JSONDecodeError :
576- message = response .text or "unknown error"
577- raise ValueError (f"Error fetching block parent page ID: { message } " )
578- response_json = response .json ()
579- parent = response_json ["parent" ]
580- parent_type = parent ["type" ]
581- if parent_type == "block_id" :
582- return self .notion_block_parent_page_id (access_token , parent [parent_type ])
583- return parent [parent_type ]
556+ return self ._resolve_block_parent_page_id (block_id )
557+
558+ def _resolve_block_parent_page_id (self , block_id : str ) -> str :
559+ clean_id = block_id .replace ("-" , "" )
560+ cached = self ._parent_cache .get (clean_id )
561+ if cached is not None :
562+ return cached
563+
564+ data = self ._make_request ("get" , f"/blocks/{ clean_id } " , allow_status = (404 ,))
565+ if data is None :
566+ result = "root"
567+ else :
568+ parent = data .get ("parent" , {})
569+ parent_type = parent .get ("type" )
570+ if parent_type == "block_id" :
571+ result = self ._resolve_block_parent_page_id (parent [parent_type ])
572+ elif parent_type == "workspace" :
573+ result = "root"
574+ elif parent_type and parent_type in parent :
575+ result = parent [parent_type ]
576+ else :
577+ result = "root"
578+
579+ with self ._parent_cache_lock :
580+ self ._parent_cache [clean_id ] = result
581+ return result
0 commit comments