Files
2025-07-29 22:06:33 +08:00

595 lines
20 KiB
Python

"""
Notion API Client for Dify plugins
This module provides a unified interface for interacting with the Notion API
"""
import time
from typing import Any
import requests
from dify_plugin.entities.datasource import OnlineDocumentPage
__TIMEOUT_SECONDS__ = 60 * 10
class NotionClient:
"""
A client for interacting with the Notion API.
Abstracts the API calls and provides a unified interface for all Notion operations.
"""
_API_BASE_URL = "https://api.notion.com/v1"
_API_VERSION = "2022-06-28" # Using a stable API version_API_VERSION = "2022-06-28"
_AUTH_URL = "https://api.notion.com/v1/oauth/authorize"
_TOKEN_URL = "https://api.notion.com/v1/oauth/token"
_NOTION_PAGE_SEARCH = "https://api.notion.com/v1/search"
_NOTION_BLOCK_SEARCH = "https://api.notion.com/v1/blocks"
_NOTION_BOT_USER = "https://api.notion.com/v1/users/me"
def __init__(self, integration_token: str):
"""
Initialize the Notion client with an integration token.
Args:
integration_token: The Notion integration token for authentication
"""
self.integration_token = integration_token
self.headers = {
"Authorization": f"Bearer {integration_token}",
"Notion-Version": self._API_VERSION,
"Content-Type": "application/json",
}
def _make_request(
self,
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
max_retries: int = 3,
) -> dict[str, Any]:
"""
Make an API request to Notion with retry logic for rate limits.
Args:
method: HTTP method (get, post, patch, etc.)
endpoint: API endpoint (relative to base URL)
params: URL parameters for GET requests
json_data: JSON data for POST/PATCH requests
max_retries: Maximum number of retries for rate limiting
Returns:
Response data as dictionary
"""
url = f"{self._API_BASE_URL}{endpoint}"
retries = 0
while retries <= max_retries:
try:
response = requests.request(
method=method,
url=url,
headers=self.headers,
params=params,
json=json_data,
timeout=__TIMEOUT_SECONDS__,
)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 1))
time.sleep(retry_after)
retries += 1
continue
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
# Format error based on Notion's error response structure
if hasattr(e, "response") and e.response is not None:
try:
error_json = e.response.json()
error_message = error_json.get("message", str(e))
error_code = error_json.get("code", "unknown_error")
raise requests.exceptions.HTTPError(
f"Notion API Error: {error_code} - {error_message}",
response=e.response,
)
except ValueError:
# If not JSON response
pass
raise
except requests.exceptions.RequestException:
if retries >= max_retries:
raise
retries += 1
time.sleep(1)
# This should never happen, but just in case
raise Exception("Maximum retries exceeded")
def search(
self,
query: str,
page_size: int = 10,
start_cursor: str | None = None,
filter_obj: dict[str, Any] | None = None,
sort: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Search for pages and databases in a Notion workspace.
Args:
query: The search query string
page_size: Maximum number of results to return (max 100)
start_cursor: Cursor for pagination
filter_obj: Filter object to restrict search to specific object types
sort: Sort object to control the order of search results
Returns:
Dictionary containing search results
"""
payload = {
"query": query,
"page_size": min(page_size, 100), # Ensure page_size doesn't exceed API limit
}
if start_cursor:
payload["start_cursor"] = start_cursor
if filter_obj:
payload["filter"] = filter_obj
if sort:
payload["sort"] = sort
return self._make_request("post", "/search", json_data=payload)
def query_database(
self,
database_id: str,
filter_obj: dict[str, Any] | None = None,
sorts: list[dict[str, Any]] | None = None,
page_size: int = 10,
start_cursor: str | None = None,
) -> dict[str, Any]:
"""
Query a Notion database with optional filtering and sorting.
Args:
database_id: The ID of the database to query
filter_obj: Optional filter object according to Notion API specs
sorts: Optional sort specifications
page_size: Maximum number of results to return (max 100)
start_cursor: Cursor for pagination
Returns:
Dictionary containing database query results
"""
# Clean database_id (remove dashes if present)
database_id = database_id.replace("-", "")
payload: dict[str, Any] = {
"page_size": min(page_size, 100) # Ensure page_size doesn't exceed API limit
}
if filter_obj:
payload["filter"] = filter_obj
if sorts:
payload["sorts"] = sorts
if start_cursor:
payload["start_cursor"] = start_cursor
return self._make_request("post", f"/databases/{database_id}/query", json_data=payload)
def retrieve_block_children(
self, block_id: str, page_size: int = 100, start_cursor: str | None = None
) -> dict[str, Any]:
"""
Retrieve the children blocks of a block.
Args:
block_id: The ID of the block to retrieve children from
page_size: Maximum number of results to return (max 100)
start_cursor: Cursor for pagination
Returns:
Dictionary containing the block children
"""
block_id = block_id.replace("-", "")
params: dict[str, Any] = {
"page_size": min(page_size, 100) # Ensure page_size doesn't exceed API limit
}
if start_cursor:
params["start_cursor"] = start_cursor
return self._make_request("get", f"/blocks/{block_id}/children", params=params)
def append_block_children(self, block_id: str, children: list[dict[str, Any]]) -> dict[str, Any]:
"""
Append children blocks to a block.
Args:
block_id: The ID of the block to append children to
children: List of block contents to append
Returns:
Dictionary containing the updated block information
"""
block_id = block_id.replace("-", "")
payload = {"children": children}
return self._make_request("patch", f"/blocks/{block_id}/children", json_data=payload)
def retrieve_page(self, page_id: str) -> dict[str, Any]:
"""
Retrieve a page by its ID.
Args:
page_id: The ID of the page to retrieve
Returns:
Dictionary containing the page information
"""
page_id = page_id.replace("-", "")
return self._make_request("get", f"/pages/{page_id}")
def retrieve_database(self, database_id: str) -> dict[str, Any]:
"""
Retrieve a database by its ID.
Args:
database_id: The ID of the database to retrieve
Returns:
Dictionary containing the database information
"""
database_id = database_id.replace("-", "")
return self._make_request("get", f"/databases/{database_id}")
def create_property_filter(
self, property_name: str, property_type: str, condition: str, value: Any
) -> dict[str, Any]:
"""
Create a property filter for database queries.
Args:
property_name: Name of the property to filter on
property_type: Type of the property (text, number, checkbox, etc.)
condition: Filter condition (equals, contains, greater_than, etc.)
value: Value to filter by
Returns:
Filter object for use with query_database
"""
return {"property": property_name, property_type: {condition: value}}
def create_simple_text_filter(
self, property_name: str, filter_value: str, condition: str = "equals"
) -> dict[str, Any]:
"""
Create a simple text filter for database queries.
Args:
property_name: Name of the property to filter on
filter_value: Value to filter by
condition: Filter condition (equals, contains, starts_with, ends_with)
Returns:
Filter object for use with query_database
"""
return self.create_property_filter(property_name, "rich_text", condition, filter_value)
def format_page_url(self, page_id: str) -> str:
"""
Format a page ID into a Notion URL.
Args:
page_id: The page ID
Returns:
Formatted Notion URL for the page
"""
# Make sure the page_id is properly formatted (with hyphens)
clean_id = page_id.replace("-", "")
formatted_id = f"{clean_id[0:8]}-{clean_id[8:12]}-{clean_id[12:16]}-{clean_id[16:20]}-{clean_id[20:]}"
return f"https://notion.so/{formatted_id}"
def extract_plain_text(self, rich_text_array: list[dict[str, Any]]) -> str:
"""
Extract plain text from a rich text array.
Args:
rich_text_array: Array of rich text objects
Returns:
Plain text string
"""
if not rich_text_array:
return ""
return "".join([text.get("plain_text", "") for text in rich_text_array])
def create_rich_text(self, content: str, annotations: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"""
Create a rich text array with the specified content and optional annotations.
Args:
content: The text content
annotations: Optional text annotations (bold, italic, etc.)
Returns:
Rich text array for use with Notion API
"""
rich_text = {"type": "text", "text": {"content": content}}
if annotations:
rich_text["annotations"] = annotations
return [rich_text]
def create_paragraph_block(self, text_content: str) -> dict[str, Any]:
"""
Create a paragraph block with the specified text content.
Args:
text_content: The text content of the paragraph
Returns:
Paragraph block for use with Notion API
"""
return {
"object": "block",
"type": "paragraph",
"paragraph": {"rich_text": self.create_rich_text(text_content)},
}
def create_heading_block(self, text_content: str, level: int = 1) -> dict[str, Any]:
"""
Create a heading block with the specified text content and level.
Args:
text_content: The text content of the heading
level: Heading level (1, 2, or 3)
Returns:
Heading block for use with Notion API
"""
if level not in [1, 2, 3]:
raise ValueError("Heading level must be 1, 2, or 3")
heading_type = f"heading_{level}"
return {
"object": "block",
"type": heading_type,
heading_type: {"rich_text": self.create_rich_text(text_content)},
}
def create_bulleted_list_block(self, text_content: str) -> dict[str, Any]:
"""
Create a bulleted list item block with the specified text content.
Args:
text_content: The text content of the list item
Returns:
Bulleted list item block for use with Notion API
"""
return {
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": {"rich_text": self.create_rich_text(text_content)},
}
def create_numbered_list_block(self, text_content: str) -> dict[str, Any]:
"""
Create a numbered list item block with the specified text content.
Args:
text_content: The text content of the list item
Returns:
Numbered list item block for use with Notion API
"""
return {
"object": "block",
"type": "numbered_list_item",
"numbered_list_item": {"rich_text": self.create_rich_text(text_content)},
}
def retrieve_comments(self, block_id: str, page_size: int = 100, start_cursor: str | None = None) -> dict[str, Any]:
"""
Retrieve comments from a block or page.
Args:
block_id: The ID of the block or page
page_size: Maximum number of comments to retrieve
start_cursor: Pagination cursor
Returns:
Comments data including results array
"""
endpoint = "/comments"
params = {"block_id": block_id, "page_size": page_size}
if start_cursor:
params["start_cursor"] = start_cursor
return self._make_request("GET", endpoint, params=params)
def format_rich_text(self, content: str) -> list[dict[str, Any]]:
"""
Format plain text into rich text array for Notion API.
Wrapper around create_rich_text for simplified usage.
Args:
content: Plain text content
Returns:
Rich text array for Notion API
"""
return self.create_rich_text(content)
def get_authorized_pages(self):
pages = []
access_token = self.integration_token
page_results = self.notion_page_search(access_token)
database_results = self.notion_database_search(access_token)
# get page detail
for page_result in page_results:
page_id = page_result["id"]
page_name = "Untitled"
for key in page_result["properties"]:
if "title" in page_result["properties"][key] and page_result["properties"][key]["title"]:
title_list = page_result["properties"][key]["title"]
if len(title_list) > 0 and "plain_text" in title_list[0]:
page_name = title_list[0]["plain_text"]
page_icon = page_result["icon"]
if page_icon:
icon_type = page_icon["type"]
if icon_type in {"external", "file"}:
url = page_icon[icon_type]["url"]
icon = {
"type": "url",
"url": url if url.startswith("http") else f"https://www.notion.so{url}",
}
else:
icon = {"type": "emoji", "emoji": page_icon[icon_type]}
else:
icon = None
parent = page_result["parent"]
parent_type = parent["type"]
if parent_type == "block_id":
parent_id = self.notion_block_parent_page_id(access_token, parent[parent_type])
elif parent_type == "workspace":
parent_id = "root"
else:
parent_id = parent[parent_type]
page = OnlineDocumentPage(
page_id=page_id,
page_name=page_name,
page_icon=icon,
parent_id=parent_id,
type="page",
last_edited_time=page_result["last_edited_time"],
)
pages.append(page)
# get database detail
for database_result in database_results:
page_id = database_result["id"]
page_name = database_result["title"][0]["plain_text"] if len(database_result["title"]) > 0 else "Untitled"
page_icon = database_result["icon"]
if page_icon:
icon_type = page_icon["type"]
if icon_type in {"external", "file"}:
url = page_icon[icon_type]["url"]
icon = {
"type": "url",
"url": url if url.startswith("http") else f"https://www.notion.so{url}",
}
else:
icon = {"type": "emoji", "emoji": page_icon[icon_type]}
else:
icon = None
parent = database_result["parent"]
parent_type = parent["type"]
if parent_type == "block_id":
parent_id = self.notion_block_parent_page_id(access_token, parent[parent_type])
elif parent_type == "workspace":
parent_id = "root"
else:
parent_id = parent[parent_type]
page = OnlineDocumentPage(
page_id=page_id,
page_name=page_name,
page_icon=icon,
parent_id=parent_id,
type="database",
last_edited_time=database_result["last_edited_time"],
)
pages.append(page)
return pages
def notion_page_search(self, access_token: str):
results = []
next_cursor = None
has_more = True
while has_more:
data: dict[str, Any] = {
"filter": {"value": "page", "property": "object"},
**({"start_cursor": next_cursor} if next_cursor else {}),
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"Notion-Version": self._API_VERSION,
}
response = requests.post(
url=self._NOTION_PAGE_SEARCH, json=data, headers=headers, timeout=__TIMEOUT_SECONDS__
)
response_json = response.json()
results.extend(response_json.get("results", []))
has_more = response_json.get("has_more", False)
next_cursor = response_json.get("next_cursor", None)
return results
def notion_database_search(self, access_token: str):
results = []
next_cursor = None
has_more = True
while has_more:
data: dict[str, Any] = {
"filter": {"value": "database", "property": "object"},
**({"start_cursor": next_cursor} if next_cursor else {}),
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"Notion-Version": self._API_VERSION,
}
response = requests.post(
url=self._NOTION_PAGE_SEARCH, json=data, headers=headers, timeout=__TIMEOUT_SECONDS__
)
response_json = response.json()
results.extend(response_json.get("results", []))
has_more = response_json.get("has_more", False)
next_cursor = response_json.get("next_cursor", None)
return results
def notion_block_parent_page_id(self, access_token: str, block_id: str):
headers = {
"Authorization": f"Bearer {access_token}",
"Notion-Version": self._API_VERSION,
}
response = requests.get(
url=f"{self._NOTION_BLOCK_SEARCH}/{block_id}", headers=headers, timeout=__TIMEOUT_SECONDS__
)
response_json = response.json()
if response.status_code != 200:
message = response_json.get("message", "unknown error")
raise ValueError(f"Error fetching block parent page ID: {message}")
parent = response_json["parent"]
parent_type = parent["type"]
if parent_type == "block_id":
return self.notion_block_parent_page_id(access_token, parent[parent_type])
return parent[parent_type]