Coverage for src/qdrant_loader/connectors/confluence/connector.py: 71%

399 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1import re 

2from collections.abc import AsyncIterator 

3from datetime import datetime 

4from urllib.parse import quote, urljoin 

5 

6import requests 

7 

8from qdrant_loader.config.types import SourceType 

9from qdrant_loader.connectors.base import BaseConnector 

10from qdrant_loader.connectors.confluence.auth import ( 

11 auto_detect_deployment_type as _auto_detect_type, 

12) 

13from qdrant_loader.connectors.confluence.auth import setup_authentication as _setup_auth 

14from qdrant_loader.connectors.confluence.config import ( 

15 ConfluenceDeploymentType, 

16 ConfluenceSpaceConfig, 

17) 

18from qdrant_loader.connectors.confluence.mappers import ( 

19 extract_hierarchy_info as _extract_hierarchy_info_helper, 

20) 

21from qdrant_loader.connectors.confluence.pagination import ( 

22 CONTENT_EXPAND as _CONTENT_EXPAND, 

23) 

24from qdrant_loader.connectors.confluence.pagination import ( 

25 build_cloud_search_params as _build_cloud_params, 

26) 

27from qdrant_loader.connectors.confluence.pagination import ( 

28 build_dc_search_params as _build_dc_params, 

29) 

30from qdrant_loader.connectors.shared.attachments import AttachmentReader 

31from qdrant_loader.connectors.shared.attachments.metadata import ( 

32 confluence_attachment_to_metadata, 

33) 

34from qdrant_loader.connectors.shared.http import ( 

35 RateLimiter, 

36) 

37from qdrant_loader.connectors.shared.http import ( 

38 request_with_policy as _http_request_with_policy, 

39) 

40from qdrant_loader.core.attachment_downloader import AttachmentMetadata 

41from qdrant_loader.core.document import Document 

42from qdrant_loader.core.file_conversion import ( 

43 FileConversionConfig, 

44 FileConverter, 

45 FileDetector, 

46) 

47from qdrant_loader.utils.logging import LoggingConfig 

48 

49logger = LoggingConfig.get_logger(__name__) 

50 

51 

52class ConfluenceConnector(BaseConnector): 

53 """Connector for Atlassian Confluence.""" 

54 

55 def __init__(self, config: ConfluenceSpaceConfig): 

56 """Initialize the connector with configuration. 

57 

58 Args: 

59 config: Confluence configuration 

60 """ 

61 super().__init__(config) 

62 self.config = config 

63 self.base_url = config.base_url 

64 

65 # Initialize session 

66 self.session = requests.Session() 

67 # Rate limiter (configurable RPM) 

68 self._rate_limiter = RateLimiter.per_minute( 

69 getattr(self.config, "requests_per_minute", 60) 

70 ) 

71 

72 # Set up authentication based on deployment type 

73 self._setup_authentication() 

74 self._initialized = False 

75 

76 # Initialize file conversion and attachment handling components 

77 self.file_converter = None 

78 self.file_detector = None 

79 self.attachment_downloader = None 

80 

81 if self.config.enable_file_conversion: 

82 logger.info("File conversion enabled for Confluence connector") 

83 # File conversion config will be set from global config during ingestion 

84 self.file_detector = FileDetector() 

85 else: 

86 logger.debug("File conversion disabled for Confluence connector") 

87 

88 def set_file_conversion_config(self, file_conversion_config: FileConversionConfig): 

89 """Set file conversion configuration from global config. 

90 

91 Args: 

92 file_conversion_config: Global file conversion configuration 

93 """ 

94 if self.config.enable_file_conversion: 

95 self.file_converter = FileConverter(file_conversion_config) 

96 

97 # Initialize attachment downloader if download_attachments is enabled 

98 if self.config.download_attachments: 

99 from qdrant_loader.core.attachment_downloader import ( 

100 AttachmentDownloader, 

101 ) 

102 

103 downloader = AttachmentDownloader( 

104 session=self.session, 

105 file_conversion_config=file_conversion_config, 

106 enable_file_conversion=True, 

107 max_attachment_size=file_conversion_config.max_file_size, 

108 ) 

109 self.attachment_downloader = AttachmentReader( 

110 session=self.session, downloader=downloader 

111 ) 

112 logger.info("Attachment reader initialized with file conversion") 

113 else: 

114 logger.debug("Attachment downloading disabled") 

115 

116 logger.debug("File converter initialized with global config") 

117 

118 def _setup_authentication(self): 

119 """Set up authentication based on deployment type.""" 

120 _setup_auth(self.session, self.config) 

121 

122 def _auto_detect_deployment_type(self) -> ConfluenceDeploymentType: 

123 """Auto-detect the Confluence deployment type based on the base URL. 

124 

125 Returns: 

126 ConfluenceDeploymentType: Detected deployment type 

127 """ 

128 return _auto_detect_type(str(self.base_url)) 

129 

130 async def __aenter__(self): 

131 """Async context manager entry.""" 

132 if not self._initialized: 

133 self._initialized = True 

134 return self 

135 

136 async def __aexit__(self, exc_type, exc_val, _exc_tb): 

137 """Async context manager exit.""" 

138 self._initialized = False 

139 

140 def _get_api_url(self, endpoint: str) -> str: 

141 """Construct the full API URL for an endpoint. 

142 

143 Args: 

144 endpoint: API endpoint path 

145 

146 Returns: 

147 str: Full API URL 

148 """ 

149 return f"{self.base_url}/rest/api/{endpoint}" 

150 

151 async def _make_request(self, method: str, endpoint: str, **kwargs) -> dict: 

152 """Make an authenticated request to the Confluence API. 

153 

154 Args: 

155 method: HTTP method 

156 endpoint: API endpoint path 

157 **kwargs: Additional request parameters 

158 

159 Returns: 

160 dict: Response data 

161 

162 Raises: 

163 requests.exceptions.RequestException: If the request fails 

164 """ 

165 url = self._get_api_url(endpoint) 

166 try: 

167 if not self.session.headers.get("Authorization"): 

168 kwargs["auth"] = self.session.auth 

169 

170 response = await _http_request_with_policy( 

171 self.session, 

172 method, 

173 url, 

174 rate_limiter=self._rate_limiter, 

175 retries=3, 

176 backoff_factor=0.5, 

177 status_forcelist=(429, 500, 502, 503, 504), 

178 overall_timeout=90.0, 

179 **kwargs, 

180 ) 

181 response.raise_for_status() 

182 return response.json() 

183 except requests.exceptions.RequestException as e: 

184 logger.error(f"Failed to make request to {url}: {e}") 

185 logger.error( 

186 "Request details", 

187 method=method, 

188 url=url, 

189 deployment_type=self.config.deployment_type, 

190 has_auth_header=bool(self.session.headers.get("Authorization")), 

191 has_session_auth=bool(self.session.auth), 

192 ) 

193 raise 

194 

195 async def _get_space_content_cloud( 

196 self, cursor: str | None = None, light: bool = False 

197 ) -> dict: 

198 """Fetch content from a Confluence Cloud space using cursor-based pagination. 

199 

200 Args: 

201 cursor: Cursor for pagination. If None, starts from the beginning. 

202 light: If True, request a minimal `expand` (no page bodies) for 

203 cheap ID-only listing. 

204 

205 Returns: 

206 dict: Response containing space content 

207 """ 

208 # Build params via helper 

209 params = _build_cloud_params( 

210 self.config.space_key, self.config.content_types, cursor, light=light 

211 ) 

212 

213 logger.debug( 

214 "Making Confluence Cloud API request", 

215 url=f"{self.base_url}/rest/api/content/search", 

216 params=params, 

217 ) 

218 response = await self._make_request("GET", "content/search", params=params) 

219 if response and "results" in response: 

220 # For Cloud, we can't easily calculate page numbers from cursor, so just log occasionally 

221 if len(response["results"]) > 0: 

222 logger.debug( 

223 f"Fetching Confluence Cloud documents: {len(response['results'])} found", 

224 count=len(response["results"]), 

225 total_size=response.get("totalSize", response.get("size", 0)), 

226 ) 

227 return response 

228 

229 async def _get_space_content_datacenter( 

230 self, start: int = 0, light: bool = False 

231 ) -> dict: 

232 """Fetch content from a Confluence Data Center space using start/limit pagination. 

233 

234 Args: 

235 start: Starting index for pagination. Defaults to 0. 

236 light: If True, request a minimal `expand` (no page bodies) for 

237 cheap ID-only listing. 

238 

239 Returns: 

240 dict: Response containing space content 

241 """ 

242 params = _build_dc_params( 

243 self.config.space_key, self.config.content_types, start, light=light 

244 ) 

245 

246 logger.debug( 

247 "Making Confluence Data Center API request", 

248 url=f"{self.base_url}/rest/api/content/search", 

249 params=params, 

250 ) 

251 response = await self._make_request("GET", "content/search", params=params) 

252 if response and "results" in response: 

253 # Only log every 10th page to reduce verbosity 

254 page_num = start // 25 + 1 

255 if page_num == 1 or page_num % 10 == 0: 

256 logger.debug( 

257 f"Fetching Confluence Data Center documents (page {page_num}): {len(response['results'])} found", 

258 count=len(response["results"]), 

259 total_size=response.get("totalSize", response.get("size", 0)), 

260 start=start, 

261 ) 

262 return response 

263 

264 async def _get_space_content(self, start: int = 0) -> dict: 

265 """Backward compatibility method for tests. 

266 

267 Args: 

268 start: Starting index for pagination. Defaults to 0. 

269 

270 Returns: 

271 dict: Response containing space content 

272 """ 

273 if self.config.deployment_type == ConfluenceDeploymentType.CLOUD: 

274 # For Cloud, ignore start parameter and use cursor=None 

275 return await self._get_space_content_cloud(None) 

276 else: 

277 # For Data Center, use start parameter 

278 return await self._get_space_content_datacenter(start) 

279 

280 async def _get_content_attachments( 

281 self, content_id: str 

282 ) -> list[AttachmentMetadata]: 

283 """Fetch attachments for a specific content item. 

284 

285 Args: 

286 content_id: ID of the content item 

287 

288 Returns: 

289 List of attachment metadata 

290 """ 

291 if not self.config.download_attachments: 

292 return [] 

293 

294 try: 

295 # Fetch attachments using Confluence API 

296 endpoint = f"content/{content_id}/child/attachment" 

297 params = { 

298 "expand": "metadata,version,history", # Include history for better metadata 

299 "limit": 50, # Reasonable limit for attachments per page 

300 } 

301 

302 response = await self._make_request("GET", endpoint, params=params) 

303 attachments = [] 

304 

305 for attachment_data in response.get("results", []): 

306 try: 

307 translated = confluence_attachment_to_metadata( 

308 attachment_data, 

309 base_url=str(self.base_url), 

310 parent_id=content_id, 

311 ) 

312 if translated is None: 

313 logger.warning( 

314 "No download link found for attachment", 

315 attachment_id=attachment_data.get("id"), 

316 filename=attachment_data.get("title"), 

317 deployment_type=self.config.deployment_type, 

318 ) 

319 continue 

320 

321 attachments.append(translated) 

322 

323 logger.debug( 

324 "Found attachment", 

325 attachment_id=getattr(translated, "id", None), 

326 filename=getattr(translated, "filename", None), 

327 size=getattr(translated, "size", None), 

328 mime_type=getattr(translated, "mime_type", None), 

329 deployment_type=self.config.deployment_type, 

330 ) 

331 

332 except Exception as e: 

333 logger.warning( 

334 "Failed to process attachment metadata", 

335 attachment_id=attachment_data.get("id"), 

336 filename=attachment_data.get("title"), 

337 deployment_type=self.config.deployment_type, 

338 error=str(e), 

339 ) 

340 continue 

341 

342 logger.debug( 

343 "Found attachments for content", 

344 content_id=content_id, 

345 attachment_count=len(attachments), 

346 deployment_type=self.config.deployment_type, 

347 ) 

348 

349 return attachments 

350 

351 except Exception as e: 

352 logger.error( 

353 "Failed to fetch attachments", 

354 content_id=content_id, 

355 deployment_type=self.config.deployment_type, 

356 error=str(e), 

357 ) 

358 return [] 

359 

360 async def _process_attachments_for_document( 

361 self, content: dict, document: Document 

362 ) -> list[Document]: 

363 """Process attachments for a given content item and parent document. 

364 

365 Checks configuration flags and uses the attachment downloader to 

366 fetch and convert attachments into child documents. 

367 

368 Args: 

369 content: Confluence content item 

370 document: Parent document corresponding to the content item 

371 

372 Returns: 

373 List of generated attachment documents (may be empty) 

374 """ 

375 if not (self.config.download_attachments and self.attachment_downloader): 

376 return [] 

377 

378 try: 

379 content_id = content.get("id") 

380 attachments = await self._get_content_attachments(content_id) 

381 if not attachments: 

382 return [] 

383 

384 attachment_docs = await self.attachment_downloader.fetch_and_process( 

385 attachments, document 

386 ) 

387 logger.debug( 

388 f"Processed {len(attachment_docs)} attachments for {content.get('type')} '{content.get('title')}'", 

389 content_id=content.get("id"), 

390 ) 

391 return attachment_docs 

392 except Exception as e: 

393 logger.error( 

394 f"Failed to process attachments for {content.get('type')} '{content.get('title')}' (ID: {content.get('id')}): {e!s}" 

395 ) 

396 return [] 

397 

398 def _should_process_content(self, content: dict) -> bool: 

399 """Check if content should be processed based on labels. 

400 

401 Args: 

402 content: Content metadata from Confluence API 

403 

404 Returns: 

405 bool: True if content should be processed, False otherwise 

406 """ 

407 # Get content labels 

408 labels = { 

409 label["name"] 

410 for label in content.get("metadata", {}) 

411 .get("labels", {}) 

412 .get("results", []) 

413 } 

414 

415 # Log content details for debugging 

416 logger.debug( 

417 "Checking content for processing", 

418 content_id=content.get("id"), 

419 content_type=content.get("type"), 

420 title=content.get("title"), 

421 labels=labels, 

422 exclude_labels=self.config.exclude_labels, 

423 include_labels=self.config.include_labels, 

424 ) 

425 

426 # Check exclude labels first, if there are any specified 

427 if self.config.exclude_labels and any( 

428 label in labels for label in self.config.exclude_labels 

429 ): 

430 logger.debug( 

431 "Content excluded due to exclude labels", 

432 content_id=content.get("id"), 

433 title=content.get("title"), 

434 matching_labels=[ 

435 label for label in labels if label in self.config.exclude_labels 

436 ], 

437 ) 

438 return False 

439 

440 # If include labels are specified, content must have at least one 

441 if self.config.include_labels: 

442 has_include_label = any( 

443 label in labels for label in self.config.include_labels 

444 ) 

445 if not has_include_label: 

446 logger.debug( 

447 "Content excluded due to missing include labels", 

448 content_id=content.get("id"), 

449 title=content.get("title"), 

450 required_labels=self.config.include_labels, 

451 ) 

452 return has_include_label 

453 

454 return True 

455 

456 def _extract_hierarchy_info(self, content: dict) -> dict: 

457 """Extract page hierarchy information from Confluence content. 

458 

459 Args: 

460 content: Content item from Confluence API 

461 

462 Returns: 

463 dict: Hierarchy information including ancestors, parent, and children 

464 """ 

465 return _extract_hierarchy_info_helper(content) 

466 

467 def _process_content( 

468 self, content: dict, clean_html: bool = True 

469 ) -> Document | None: 

470 """Process a single content item from Confluence. 

471 

472 Args: 

473 content: Content item from Confluence API 

474 clean_html: Whether to clean HTML tags from content. Defaults to True. 

475 

476 Returns: 

477 Document if processing successful 

478 

479 Raises: 

480 ValueError: If required fields are missing or malformed 

481 """ 

482 try: 

483 # Extract required fields 

484 content_id = content.get("id") 

485 title = content.get("title") 

486 space = content.get("space", {}).get("key") 

487 

488 # Log content details for debugging 

489 logger.debug( 

490 "Processing content", 

491 content_id=content_id, 

492 title=title, 

493 space=space, 

494 type=content.get("type"), 

495 version=content.get("version", {}).get("number"), 

496 has_body=bool(content.get("body", {}).get("storage", {}).get("value")), 

497 comment_count=len( 

498 content.get("children", {}).get("comment", {}).get("results", []) 

499 ), 

500 label_count=len( 

501 content.get("metadata", {}).get("labels", {}).get("results", []) 

502 ), 

503 ) 

504 

505 body = content.get("body", {}).get("storage", {}).get("value") 

506 # Check for missing or malformed body 

507 if not body: 

508 logger.warning( 

509 "Content body is missing or malformed, using title as content", 

510 content_id=content_id, 

511 title=title, 

512 content_type=content.get("type"), 

513 space=space, 

514 ) 

515 # Use title as fallback content instead of failing 

516 body = title or f"[Empty page: {content_id}]" 

517 

518 # Check for other missing required fields 

519 missing_fields = [] 

520 if not content_id: 

521 missing_fields.append("id") 

522 if not title: 

523 missing_fields.append("title") 

524 if not space: 

525 missing_fields.append("space") 

526 

527 if missing_fields: 

528 logger.warning( 

529 "Content is missing required fields", 

530 content_id=content_id, 

531 title=title, 

532 content_type=content.get("type"), 

533 missing_fields=missing_fields, 

534 space=space, 

535 ) 

536 raise ValueError( 

537 f"Content is missing required fields: {', '.join(missing_fields)}" 

538 ) 

539 

540 # Get version information 

541 version = content.get("version", {}) 

542 version_number = ( 

543 version.get("number", 1) if isinstance(version, dict) else 1 

544 ) 

545 

546 # Get author information with better error handling 

547 author = None 

548 try: 

549 author = ( 

550 content.get("history", {}).get("createdBy", {}).get("displayName") 

551 ) 

552 if not author: 

553 # Fallback to version author for Data Center 

554 author = content.get("version", {}).get("by", {}).get("displayName") 

555 except (AttributeError, TypeError): 

556 logger.debug( 

557 "Could not extract author information", content_id=content_id 

558 ) 

559 

560 # Get timestamps with improved parsing for both Cloud and Data Center 

561 created_at = None 

562 updated_at = None 

563 

564 # Try to get creation date from history (both Cloud and Data Center) 

565 try: 

566 if "history" in content and "createdDate" in content["history"]: 

567 created_at = content["history"]["createdDate"] 

568 elif "history" in content and "createdAt" in content["history"]: 

569 # Alternative field name in some Data Center versions 

570 created_at = content["history"]["createdAt"] 

571 except (ValueError, TypeError, KeyError): 

572 logger.debug("Could not parse creation date", content_id=content_id) 

573 

574 # Try to get update date from version (both Cloud and Data Center) 

575 try: 

576 if "version" in content and "when" in content["version"]: 

577 updated_at = content["version"]["when"] 

578 elif "version" in content and "friendlyWhen" in content["version"]: 

579 # Some Data Center versions use friendlyWhen 

580 updated_at = content["version"]["friendlyWhen"] 

581 except (ValueError, TypeError, KeyError): 

582 logger.debug("Could not parse update date", content_id=content_id) 

583 

584 # Process comments 

585 comments = [] 

586 if "children" in content and "comment" in content["children"]: 

587 for comment in content["children"]["comment"]["results"]: 

588 comment_body = ( 

589 comment.get("body", {}).get("storage", {}).get("value", "") 

590 ) 

591 comment_author = ( 

592 comment.get("history", {}) 

593 .get("createdBy", {}) 

594 .get("displayName", "") 

595 ) 

596 comment_created = comment.get("history", {}).get("createdDate", "") 

597 comments.append( 

598 { 

599 "body": ( 

600 self._clean_html(comment_body) 

601 if clean_html 

602 else comment_body 

603 ), 

604 "author": comment_author, 

605 "created_at": comment_created, 

606 } 

607 ) 

608 

609 # Extract hierarchy information 

610 hierarchy_info = self._extract_hierarchy_info(content) 

611 

612 # Build canonical and display URLs 

613 canonical_url = self._construct_canonical_page_url( 

614 space or "", 

615 content_id or "", 

616 content.get("type", "page"), 

617 ) 

618 display_url = self._construct_page_url( 

619 space or "", 

620 content_id or "", 

621 title or "", 

622 content.get("type", "page"), 

623 ) 

624 

625 # Create metadata with all available information including hierarchy 

626 metadata = { 

627 "id": content_id, 

628 "title": title, 

629 "space": space, 

630 "version": version_number, 

631 "type": content.get("type", "unknown"), 

632 "author": author, 

633 # Human-friendly URL (kept in metadata) 

634 "display_url": display_url, 

635 "labels": [ 

636 label["name"] 

637 for label in content.get("metadata", {}) 

638 .get("labels", {}) 

639 .get("results", []) 

640 ], 

641 "comments": comments, 

642 "updated_at": updated_at, 

643 "created_at": created_at, 

644 # Page hierarchy information 

645 "hierarchy": hierarchy_info, 

646 "parent_id": hierarchy_info["parent_id"], 

647 "parent_title": hierarchy_info["parent_title"], 

648 "ancestors": hierarchy_info["ancestors"], 

649 "children": hierarchy_info["children"], 

650 "depth": hierarchy_info["depth"], 

651 "breadcrumb": hierarchy_info["breadcrumb"], 

652 "breadcrumb_text": ( 

653 " > ".join(hierarchy_info["breadcrumb"]) 

654 if hierarchy_info["breadcrumb"] 

655 else "" 

656 ), 

657 } 

658 

659 # Clean content if requested 

660 content_text = self._clean_html(body) if clean_html else body 

661 

662 # Parse timestamps for Document constructor 

663 parsed_created_at = self._parse_timestamp(created_at) 

664 parsed_updated_at = self._parse_timestamp(updated_at) 

665 

666 # Create document with all fields properly populated 

667 document = Document( 

668 title=title, 

669 content=content_text, 

670 content_type="html", 

671 metadata=metadata, 

672 source_type=SourceType.CONFLUENCE, 

673 source=self.config.source, 

674 url=canonical_url, 

675 is_deleted=False, 

676 updated_at=parsed_updated_at, 

677 created_at=parsed_created_at, 

678 ) 

679 

680 return document 

681 

682 except Exception as e: 

683 logger.error( 

684 "Failed to process content", 

685 content_id=content.get("id"), 

686 content_title=content.get("title"), 

687 content_type=content.get("type"), 

688 error=str(e), 

689 error_type=type(e).__name__, 

690 ) 

691 raise 

692 

693 def _construct_page_url( 

694 self, space: str, content_id: str, title: str, content_type: str = "page" 

695 ) -> str: 

696 """Construct the appropriate URL for a Confluence page based on deployment type. 

697 

698 Args: 

699 space: The space key 

700 content_id: The content ID 

701 title: The page title (used for Data Center URLs) 

702 content_type: The type of content (page, blogpost, etc.) 

703 

704 Returns: 

705 The constructed URL 

706 """ 

707 # Ensure base is treated as a directory to preserve any path components 

708 base = str(self.base_url) 

709 base = base if base.endswith("/") else base + "/" 

710 

711 if self.config.deployment_type == ConfluenceDeploymentType.CLOUD: 

712 # Cloud URLs use ID-based format 

713 if content_type == "blogpost": 

714 path = f"spaces/{space}/blog/{content_id}" 

715 else: 

716 path = f"spaces/{space}/pages/{content_id}" 

717 return urljoin(base, path) 

718 else: 

719 # Data Center/Server URLs - use title for better readability 

720 # URL-encode the title, replacing spaces with '+' (Confluence format) 

721 encoded_title = quote(title.replace(" ", "+"), safe="+") 

722 if content_type == "blogpost": 

723 path = f"display/{space}/{encoded_title}" 

724 else: 

725 path = f"display/{space}/{encoded_title}" 

726 return urljoin(base, path) 

727 

728 def _construct_canonical_page_url( 

729 self, space: str, content_id: str, content_type: str = "page" 

730 ) -> str: 

731 """Construct a canonical ID-based URL for both Cloud and Data Center.""" 

732 base = str(self.base_url) 

733 base = base if base.endswith("/") else base + "/" 

734 

735 if content_type == "blogpost": 

736 path = f"spaces/{space}/blog/{content_id}" 

737 else: 

738 path = f"spaces/{space}/pages/{content_id}" 

739 return urljoin(base, path) 

740 

741 def _parse_timestamp(self, timestamp_str: str | None) -> "datetime | None": 

742 """Parse a timestamp string into a datetime object. 

743 

744 Args: 

745 timestamp_str: The timestamp string to parse 

746 

747 Returns: 

748 Parsed datetime object or None if parsing fails 

749 """ 

750 if not timestamp_str: 

751 return None 

752 

753 try: 

754 import re 

755 from datetime import datetime 

756 

757 # Handle various timestamp formats from Confluence 

758 # ISO format with timezone: 2024-05-24T20:57:56.130+07:00 

759 if re.match( 

760 r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}", 

761 timestamp_str, 

762 ): 

763 return datetime.fromisoformat(timestamp_str) 

764 

765 # ISO format without microseconds: 2024-05-24T20:57:56+07:00 

766 elif re.match( 

767 r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}", timestamp_str 

768 ): 

769 return datetime.fromisoformat(timestamp_str) 

770 

771 # ISO format with Z timezone: 2024-05-24T20:57:56.130Z 

772 elif re.match( 

773 r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", timestamp_str 

774 ): 

775 return datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) 

776 

777 # ISO format without timezone: 2024-05-24T20:57:56.130 

778 elif re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}", timestamp_str): 

779 return datetime.fromisoformat(timestamp_str) 

780 

781 # Fallback: try direct parsing 

782 else: 

783 return datetime.fromisoformat(timestamp_str) 

784 

785 except (ValueError, TypeError, AttributeError) as e: 

786 logger.debug(f"Failed to parse timestamp '{timestamp_str}': {e}") 

787 return None 

788 

789 def _clean_html(self, html: str) -> str: 

790 """Clean HTML content by removing tags and special characters. 

791 

792 Args: 

793 html: HTML content to clean 

794 

795 Returns: 

796 Cleaned text 

797 """ 

798 # Remove HTML tags 

799 text = re.sub(r"<[^>]+>", " ", html) 

800 # Replace HTML entities 

801 text = text.replace("&amp;", "and") 

802 text = re.sub(r"&[^;]+;", " ", text) 

803 # Replace multiple spaces with single space 

804 text = re.sub(r"\s+", " ", text) 

805 return text.strip() 

806 

807 async def _stream_content_cloud(self) -> AsyncIterator[Document]: 

808 """Stream documents from Confluence Cloud using cursor-based pagination.""" 

809 cursor = None 

810 page_count = 0 

811 

812 while True: 

813 try: 

814 page_count += 1 

815 logger.debug( 

816 f"Fetching page {page_count} of Confluence content (cursor={cursor})" 

817 ) 

818 response = await self._get_space_content_cloud(cursor) 

819 results = response.get("results", []) 

820 

821 if not results: 

822 logger.debug("No more results found, ending pagination") 

823 break 

824 

825 logger.debug( 

826 f"Processing {len(results)} documents from page {page_count}" 

827 ) 

828 

829 # Process each content item 

830 for content in results: 

831 if self._should_process_content(content): 

832 try: 

833 document = self._process_content(content, clean_html=True) 

834 if document: 

835 yield document 

836 

837 attachment_docs = ( 

838 await self._process_attachments_for_document( 

839 content, document 

840 ) 

841 ) 

842 for attachment_doc in attachment_docs: 

843 yield attachment_doc 

844 

845 logger.debug( 

846 f"Processed {content['type']} '{content['title']}' " 

847 f"(ID: {content['id']}) from space {self.config.space_key}" 

848 ) 

849 except Exception as e: 

850 logger.error( 

851 "Failed to process Confluence content", 

852 content_type=content.get("type"), 

853 content_title=content.get("title"), 

854 content_id=content.get("id"), 

855 error=str(e), 

856 ) 

857 

858 # Get the next cursor from the response 

859 next_url = response.get("_links", {}).get("next") 

860 if not next_url: 

861 logger.debug("No next page link found, ending pagination") 

862 break 

863 

864 # Extract just the cursor value from the URL 

865 try: 

866 from urllib.parse import parse_qs, urlparse 

867 

868 parsed_url = urlparse(next_url) 

869 query_params = parse_qs(parsed_url.query) 

870 cursor = query_params.get("cursor", [None])[0] 

871 if not cursor: 

872 logger.debug("No cursor found in next URL, ending pagination") 

873 break 

874 logger.debug(f"Found next cursor: {cursor}") 

875 except (ValueError, KeyError, AttributeError) as e: 

876 logger.error(f"Failed to parse next URL: {e!s}") 

877 break 

878 

879 except (requests.exceptions.RequestException, ValueError) as e: 

880 logger.error( 

881 f"Failed to fetch content from space {self.config.space_key}: {e!s}" 

882 ) 

883 raise 

884 

885 async def _stream_content_datacenter(self) -> AsyncIterator[Document]: 

886 """Stream documents from Confluence Data Center/Server using start/limit pagination.""" 

887 start = 0 

888 limit = 25 

889 page_count = 0 

890 

891 while True: 

892 try: 

893 page_count += 1 

894 logger.debug( 

895 f"Fetching page {page_count} of Confluence content (start={start})" 

896 ) 

897 response = await self._get_space_content_datacenter(start) 

898 results = response.get("results", []) 

899 

900 if not results: 

901 logger.debug("No more results found, ending pagination") 

902 break 

903 

904 logger.debug( 

905 f"Processing {len(results)} documents from page {page_count}" 

906 ) 

907 

908 # Process each content item 

909 for content in results: 

910 if self._should_process_content(content): 

911 try: 

912 document = self._process_content(content, clean_html=True) 

913 if document: 

914 yield document 

915 

916 attachment_docs = ( 

917 await self._process_attachments_for_document( 

918 content, document 

919 ) 

920 ) 

921 for attachment_doc in attachment_docs: 

922 yield attachment_doc 

923 

924 logger.debug( 

925 f"Processed {content['type']} '{content['title']}' " 

926 f"(ID: {content['id']}) from space {self.config.space_key}" 

927 ) 

928 except Exception as e: 

929 logger.error( 

930 "Failed to process Confluence content", 

931 content_type=content.get("type"), 

932 content_title=content.get("title"), 

933 content_id=content.get("id"), 

934 error=str(e), 

935 ) 

936 

937 # Check if there are more pages 

938 total_size = response.get("totalSize", response.get("size", 0)) 

939 if start + limit >= total_size: 

940 logger.debug( 

941 f"Reached end of results: {start + limit} >= {total_size}" 

942 ) 

943 break 

944 

945 # Move to next page 

946 start += limit 

947 logger.debug(f"Moving to next page with start={start}") 

948 

949 except (requests.exceptions.RequestException, ValueError) as e: 

950 logger.error( 

951 f"Failed to fetch content from space {self.config.space_key}: {e!s}" 

952 ) 

953 raise 

954 

955 async def stream_documents( 

956 self, since: datetime | None = None 

957 ) -> AsyncIterator[Document]: 

958 """Stream documents from Confluence (WS-1 connector contract).""" 

959 count = 0 

960 

961 if self.config.deployment_type == ConfluenceDeploymentType.CLOUD: 

962 async for document in self._stream_content_cloud(): 

963 count += 1 

964 yield document 

965 else: 

966 async for document in self._stream_content_datacenter(): 

967 count += 1 

968 yield document 

969 

970 logger.info( 

971 f"📄 Confluence: {count} documents from space {self.config.space_key}" 

972 ) 

973 

974 async def get_documents(self) -> list[Document]: 

975 """Get documents from Confluence (DEPRECATED - use stream_documents).""" 

976 return await super().get_documents() 

977 

978 async def fetch_by_id(self, entity_id: str) -> Document | None: 

979 """Fetch a single Confluence content item by its content ID.""" 

980 try: 

981 params = {"expand": _CONTENT_EXPAND} 

982 safe_entity_id = quote(entity_id, safe="") 

983 content = await self._make_request( 

984 "GET", f"content/{safe_entity_id}", params=params 

985 ) 

986 if not content or not self._should_process_content(content): 

987 return None 

988 return self._process_content(content, clean_html=True) 

989 except Exception as e: 

990 logger.error( 

991 "Failed to fetch Confluence content by id", 

992 content_id=entity_id, 

993 error=str(e), 

994 ) 

995 return None 

996 

997 async def _stream_content_ids_cloud(self) -> AsyncIterator[str]: 

998 """Stream content IDs from Confluence Cloud using a lightweight expand.""" 

999 cursor = None 

1000 

1001 while True: 

1002 response = await self._get_space_content_cloud(cursor, light=True) 

1003 results = response.get("results", []) 

1004 if not results: 

1005 break 

1006 

1007 for content in results: 

1008 if self._should_process_content(content): 

1009 yield content["id"] 

1010 

1011 next_url = response.get("_links", {}).get("next") 

1012 if not next_url: 

1013 break 

1014 

1015 try: 

1016 from urllib.parse import parse_qs, urlparse 

1017 

1018 parsed_url = urlparse(next_url) 

1019 query_params = parse_qs(parsed_url.query) 

1020 cursor = query_params.get("cursor", [None])[0] 

1021 if not cursor: 

1022 break 

1023 except (ValueError, KeyError, AttributeError): 

1024 break 

1025 

1026 async def _stream_content_ids_datacenter(self) -> AsyncIterator[str]: 

1027 """Stream content IDs from Confluence Data Center using a lightweight expand.""" 

1028 start = 0 

1029 limit = 25 

1030 

1031 while True: 

1032 response = await self._get_space_content_datacenter(start, light=True) 

1033 results = response.get("results", []) 

1034 if not results: 

1035 break 

1036 

1037 for content in results: 

1038 if self._should_process_content(content): 

1039 yield content["id"] 

1040 

1041 total_size = response.get("totalSize", response.get("size", 0)) 

1042 if start + limit >= total_size: 

1043 break 

1044 start += limit 

1045 

1046 async def list_entity_ids(self) -> AsyncIterator[str]: 

1047 """Stream content IDs for all processable content in the configured space.""" 

1048 if self.config.deployment_type == ConfluenceDeploymentType.CLOUD: 

1049 async for content_id in self._stream_content_ids_cloud(): 

1050 yield content_id 

1051 else: 

1052 async for content_id in self._stream_content_ids_datacenter(): 

1053 yield content_id