1- #!/usr/bin/env -S uv run --script
2- # /// script
3- # requires-python = ">=3.14"
4- # dependencies = [
5- # "atlassian-python-api>=4.0.7",
6- # "pydantic>=2.12.5",
7- # ]
8- # ///
9- """Confluence API 客户端封装。"""
1+ """Confluence REST API 客户端封装。"""
102
113from __future__ import annotations
124
5+ import base64
6+ from pathlib import Path
137from typing import Any
148
15- from atlassian import Confluence
9+ import httpxyz
1610from pydantic import BaseModel , Field
1711
1812DEFAULT_TIMEOUT_SECONDS = 30.0
2115class ConfluenceApiError (RuntimeError ):
2216 """Confluence API 错误。"""
2317
24- def __init__ (self , message : str , status_code : int | None = None ) -> None :
25- """初始化 API 错误。"""
18+ def __init__ (
19+ self ,
20+ message : str ,
21+ * ,
22+ status_code : int | None = None ,
23+ payload : Any | None = None ,
24+ ) -> None :
2625 super ().__init__ (message )
2726 self .status_code = status_code
27+ self .payload = payload
2828
2929
3030class ConfluenceConfig (BaseModel ):
@@ -43,45 +43,98 @@ class ConfluenceConfig(BaseModel):
4343
4444
4545class ConfluenceApiClient :
46- """Confluence API 客户端封装 。"""
46+ """Confluence REST API 薄封装 。"""
4747
4848 def __init__ (self , config : ConfluenceConfig ) -> None :
49- """初始化 Confluence API 客户端。"""
5049 self .config = config
51- kwargs : dict [str , Any ] = {
52- "url" : config .base_url ,
53- "timeout" : config .timeout_seconds ,
54- "verify_ssl" : config .verify_ssl ,
55- }
56- if config .cloud is not None :
57- kwargs ["cloud" ] = config .cloud
50+ self .client = httpxyz .Client (
51+ base_url = config .base_url .rstrip ("/" ) + "/" ,
52+ timeout = config .timeout_seconds ,
53+ verify = config .verify_ssl ,
54+ headers = self ._build_headers (config ),
55+ )
56+
57+ @staticmethod
58+ def _build_headers (config : ConfluenceConfig ) -> dict [str , str ]:
59+ headers = {"Accept" : "application/json" }
5860 if config .username :
59- kwargs [ "username" ] = config .username
60- kwargs [ "password " ] = config . token
61+ raw = f" { config .username } : { config . token } " . encode ( "utf-8" )
62+ headers [ "Authorization " ] = f"Basic { base64 . b64encode ( raw ). decode ( 'utf-8' ) } "
6163 else :
62- kwargs ["token" ] = config .token
63- self .client = Confluence (** kwargs )
64+ headers ["Authorization" ] = f"Bearer { config .token } "
65+ return headers
66+
67+ @staticmethod
68+ def _raise_for_error (response : httpxyz .Response , context : str ) -> None :
69+ if response .is_success :
70+ return
71+ payload : Any
72+ try :
73+ payload = response .json ()
74+ except ValueError :
75+ payload = response .text
76+ raise ConfluenceApiError (
77+ f"{ context } failed with status { response .status_code } " ,
78+ status_code = response .status_code ,
79+ payload = payload ,
80+ )
81+
82+ @staticmethod
83+ def _encode_body (body : str , representation : str ) -> dict [str , Any ]:
84+ return {representation : {"value" : body , "representation" : representation }}
85+
86+ def _get (self , path : str , * , params : dict [str , Any ] | None = None ) -> Any :
87+ response = self .client .get (path , params = params )
88+ self ._raise_for_error (response , f"GET { path } " )
89+ return response .json ()
90+
91+ def _post (
92+ self ,
93+ path : str ,
94+ * ,
95+ json_data : dict [str , Any ] | None = None ,
96+ files : Any | None = None ,
97+ headers : dict [str , str ] | None = None ,
98+ ) -> Any :
99+ response = self .client .post (path , json = json_data , files = files , headers = headers )
100+ self ._raise_for_error (response , f"POST { path } " )
101+ return response .json ()
102+
103+ def _put (
104+ self ,
105+ path : str ,
106+ * ,
107+ json_data : dict [str , Any ],
108+ params : dict [str , Any ] | None = None ,
109+ ) -> Any :
110+ response = self .client .put (path , json = json_data , params = params )
111+ self ._raise_for_error (response , f"PUT { path } " )
112+ return response .json ()
64113
65114 def list_spaces (self , start : int = 0 , limit : int = 25 , expand : str | None = None ) -> Any :
66- """列出空间列表。"""
67- return self .client .get_all_spaces (start = start , limit = limit , expand = expand )
115+ params : dict [str , Any ] = {"start" : start , "limit" : limit }
116+ if expand :
117+ params ["expand" ] = expand
118+ return self ._get ("rest/api/space" , params = params )
68119
69120 def get_space (self , space_key : str , expand : str | None = None ) -> Any :
70- """获取空间详情。"""
71- return self .client . get_space ( space_key , expand = expand )
121+ params = { "expand" : expand } if expand else None
122+ return self ._get ( f"rest/api/space/ { space_key } " , params = params )
72123
73124 def get_page (self , page_id : str , expand : str | None = None ) -> Any :
74- """按页面 ID 获取页面。"""
75- return self .client . get_page_by_id ( page_id , expand = expand )
125+ params = { "expand" : expand } if expand else None
126+ return self ._get ( f"rest/api/content/ { page_id } " , params = params )
76127
77128 def get_page_by_title (
78129 self ,
79130 space_key : str ,
80131 title : str ,
81132 expand : str | None = None ,
82133 ) -> Any :
83- """按标题获取页面。"""
84- return self .client .get_page_by_title (space_key , title , expand = expand )
134+ params : dict [str , Any ] = {"spaceKey" : space_key , "title" : title }
135+ if expand :
136+ params ["expand" ] = expand
137+ return self ._get ("rest/api/content" , params = params )
85138
86139 def get_page_children (
87140 self ,
@@ -90,14 +143,10 @@ def get_page_children(
90143 limit : int = 25 ,
91144 expand : str | None = None ,
92145 ) -> Any :
93- """获取子页面列表。"""
94- return self .client .get_page_child_by_type (
95- page_id ,
96- type = "page" ,
97- start = start ,
98- limit = limit ,
99- expand = expand ,
100- )
146+ params : dict [str , Any ] = {"start" : start , "limit" : limit }
147+ if expand :
148+ params ["expand" ] = expand
149+ return self ._get (f"rest/api/content/{ page_id } /child/page" , params = params )
101150
102151 def get_page_attachments (
103152 self ,
@@ -106,14 +155,10 @@ def get_page_attachments(
106155 limit : int = 25 ,
107156 expand : str | None = None ,
108157 ) -> Any :
109- """获取页面附件列表。"""
110158 params : dict [str , Any ] = {"start" : start , "limit" : limit }
111159 if expand :
112160 params ["expand" ] = expand
113- return self .client .get (
114- f"rest/api/content/{ page_id } /child/attachment" ,
115- params = params ,
116- )
161+ return self ._get (f"rest/api/content/{ page_id } /child/attachment" , params = params )
117162
118163 def create_page (
119164 self ,
@@ -123,14 +168,15 @@ def create_page(
123168 parent_id : str | None = None ,
124169 representation : str = "storage" ,
125170 ) -> Any :
126- """创建页面。"""
127- return self .client .create_page (
128- space = space_key ,
129- title = title ,
130- body = body ,
131- parent_id = parent_id ,
132- representation = representation ,
133- )
171+ data : dict [str , Any ] = {
172+ "type" : "page" ,
173+ "title" : title ,
174+ "space" : {"key" : space_key },
175+ "body" : self ._encode_body (body , representation ),
176+ }
177+ if parent_id :
178+ data ["ancestors" ] = [{"type" : "page" , "id" : parent_id }]
179+ return self ._post ("rest/api/content" , json_data = data )
134180
135181 def update_page (
136182 self ,
@@ -139,15 +185,25 @@ def update_page(
139185 body : str ,
140186 parent_id : str | None = None ,
141187 representation : str = "storage" ,
188+ always_update : bool = False ,
142189 ) -> Any :
143- """更新页面。"""
144- return self .client .update_page (
145- page_id = page_id ,
146- title = title ,
147- body = body ,
148- parent_id = parent_id ,
149- representation = representation ,
150- )
190+ current = self .get_page (page_id , expand = "version" )
191+ version = current .get ("version" , {}).get ("number" )
192+ if not isinstance (version , int ):
193+ raise ConfluenceApiError (f"Failed to resolve current page version for { page_id } " )
194+
195+ data : dict [str , Any ] = {
196+ "id" : page_id ,
197+ "type" : "page" ,
198+ "title" : title ,
199+ "version" : {"number" : version + 1 },
200+ "body" : self ._encode_body (body , representation ),
201+ }
202+ if parent_id :
203+ data ["ancestors" ] = [{"type" : "page" , "id" : parent_id }]
204+ if always_update :
205+ data ["version" ]["minorEdit" ] = False
206+ return self ._put (f"rest/api/content/{ page_id } " , json_data = data , params = {"status" : "current" })
151207
152208 def attach_file (
153209 self ,
@@ -156,13 +212,28 @@ def attach_file(
156212 title : str | None = None ,
157213 comment : str | None = None ,
158214 ) -> Any :
159- """上传附件到页面。"""
160- return self .client .attach_file (
161- filename = file_path ,
162- page_id = page_id ,
163- title = title ,
164- comment = comment ,
165- )
215+ path = Path (file_path )
216+ if not path .exists ():
217+ raise ConfluenceApiError (f"Attachment file not found: { file_path } " )
218+ filename = title or path .name
219+ existing_attachment_id = self ._find_attachment_id (page_id , filename )
220+ with path .open ("rb" ) as file_obj :
221+ files = {
222+ "file" : (filename , file_obj , "application/octet-stream" ),
223+ "minorEdit" : (None , "true" ),
224+ }
225+ if comment :
226+ files ["comment" ] = (None , comment )
227+ target_path = (
228+ f"rest/api/content/{ page_id } /child/attachment/{ existing_attachment_id } /data"
229+ if existing_attachment_id
230+ else f"rest/api/content/{ page_id } /child/attachment"
231+ )
232+ return self ._post (
233+ target_path ,
234+ files = files ,
235+ headers = {"X-Atlassian-Token" : "no-check" },
236+ )
166237
167238 def search_cql (
168239 self ,
@@ -171,5 +242,21 @@ def search_cql(
171242 limit : int = 25 ,
172243 expand : str | None = None ,
173244 ) -> Any :
174- """执行 CQL 搜索。"""
175- return self .client .cql (cql , start = start , limit = limit , expand = expand )
245+ params : dict [str , Any ] = {"cql" : cql , "start" : start , "limit" : limit }
246+ if expand :
247+ params ["expand" ] = expand
248+ return self ._get ("rest/api/search" , params = params )
249+
250+ def _find_attachment_id (self , page_id : str , filename : str ) -> str | None :
251+ payload = self .get_page_attachments (page_id , start = 0 , limit = 200 )
252+ results = payload .get ("results" ) if isinstance (payload , dict ) else None
253+ if not isinstance (results , list ):
254+ return None
255+ for item in results :
256+ if not isinstance (item , dict ):
257+ continue
258+ if str (item .get ("title" , "" )) == filename :
259+ attachment_id = item .get ("id" )
260+ if attachment_id is not None :
261+ return str (attachment_id )
262+ return None
0 commit comments