docspan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. docspan-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,587 @@
1
+ """
2
+ Confluence space crawler for extracting pages and ADF content.
3
+
4
+ This module provides functionality to crawl Confluence spaces and extract
5
+ page content in ADF (Atlassian Document Format) for analysis and comparison.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from docspan.backends.confluence.services.confluence.base_client import (
16
+ BaseConfluenceClient,
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class PageMetadata:
22
+ """Metadata for a crawled Confluence page."""
23
+
24
+ id: str
25
+ title: str
26
+ space_key: str
27
+ version: int
28
+ status: str
29
+ created_date: str
30
+ modified_date: str
31
+ creator: Optional[str] = None
32
+ last_modifier: Optional[str] = None
33
+ parent_id: Optional[str] = None
34
+ url: Optional[str] = None
35
+ labels: List[str] = field(default_factory=list)
36
+
37
+
38
+ @dataclass
39
+ class CrawledPage:
40
+ """A crawled Confluence page with metadata and content."""
41
+
42
+ metadata: PageMetadata
43
+ adf_content: Dict[str, Any]
44
+ storage_content: Optional[str] = None
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ """Convert to dictionary for JSON serialization."""
48
+ return {
49
+ "metadata": {
50
+ "id": self.metadata.id,
51
+ "title": self.metadata.title,
52
+ "space_key": self.metadata.space_key,
53
+ "version": self.metadata.version,
54
+ "status": self.metadata.status,
55
+ "created_date": self.metadata.created_date,
56
+ "modified_date": self.metadata.modified_date,
57
+ "creator": self.metadata.creator,
58
+ "last_modifier": self.metadata.last_modifier,
59
+ "parent_id": self.metadata.parent_id,
60
+ "url": self.metadata.url,
61
+ "labels": self.metadata.labels,
62
+ },
63
+ "adf_content": self.adf_content,
64
+ "storage_content": self.storage_content,
65
+ }
66
+
67
+
68
+ class SpaceCrawler:
69
+ """
70
+ Crawl Confluence spaces to extract pages and ADF content.
71
+
72
+ This class provides methods to recursively crawl Confluence spaces,
73
+ extract page content in ADF format, and save it for analysis.
74
+ """
75
+
76
+ def __init__(self, client: BaseConfluenceClient, comment_client=None):
77
+ """
78
+ Initialize the space crawler.
79
+
80
+ Args:
81
+ client: Confluence API client
82
+ comment_client: Optional ConfluenceCommentClient for fetching page comments
83
+ """
84
+ self.client = client
85
+ self.comment_client = comment_client
86
+ self.logger = logging.getLogger(__name__)
87
+
88
+ def crawl_space(
89
+ self,
90
+ space_key: str,
91
+ max_pages: Optional[int] = None,
92
+ include_archived: bool = False,
93
+ ) -> List[CrawledPage]:
94
+ """
95
+ Crawl all pages in a Confluence space.
96
+
97
+ Args:
98
+ space_key: Confluence space key to crawl
99
+ max_pages: Maximum number of pages to crawl (None for all)
100
+ include_archived: Whether to include archived pages
101
+
102
+ Returns:
103
+ List of crawled pages with metadata and content
104
+
105
+ Raises:
106
+ ConfluenceApiError: If API calls fail
107
+ """
108
+ self.logger.info(f"Starting crawl of space '{space_key}'")
109
+ crawled_pages = []
110
+
111
+ # Get all pages in the space
112
+ pages = self._get_all_pages_in_space(space_key, include_archived)
113
+
114
+ if max_pages:
115
+ pages = pages[:max_pages]
116
+
117
+ self.logger.info(f"Found {len(pages)} pages to crawl")
118
+
119
+ for i, page_info in enumerate(pages, 1):
120
+ try:
121
+ self.logger.info(f"Crawling page {i}/{len(pages)}: {page_info['title']} (ID: {page_info['id']})")
122
+ crawled_page = self._crawl_page(page_info['id'])
123
+ crawled_pages.append(crawled_page)
124
+ except Exception as e:
125
+ self.logger.error(f"Failed to crawl page {page_info['id']}: {e}")
126
+ continue
127
+
128
+ self.logger.info(f"Successfully crawled {len(crawled_pages)} pages")
129
+ return crawled_pages
130
+
131
+ def crawl_page_tree(
132
+ self,
133
+ root_page_id: str,
134
+ max_depth: Optional[int] = None,
135
+ ) -> List[CrawledPage]:
136
+ """
137
+ Crawl a page and all its descendants recursively.
138
+
139
+ Args:
140
+ root_page_id: Root page ID to start crawling from
141
+ max_depth: Maximum depth to crawl (None for unlimited)
142
+
143
+ Returns:
144
+ List of crawled pages in breadth-first order
145
+ """
146
+ self.logger.info(f"Starting tree crawl from page ID '{root_page_id}'")
147
+ crawled_pages = []
148
+
149
+ def crawl_recursive(page_id: str, current_depth: int = 0) -> None:
150
+ if max_depth is not None and current_depth > max_depth:
151
+ return
152
+
153
+ try:
154
+ # Crawl current page
155
+ crawled_page = self._crawl_page(page_id)
156
+ crawled_pages.append(crawled_page)
157
+
158
+ # Get child pages
159
+ children = self._get_child_pages(page_id)
160
+ for child in children:
161
+ crawl_recursive(child['id'], current_depth + 1)
162
+
163
+ except Exception as e:
164
+ self.logger.error(f"Failed to crawl page tree at {page_id}: {e}")
165
+
166
+ crawl_recursive(root_page_id)
167
+ self.logger.info(f"Successfully crawled {len(crawled_pages)} pages in tree")
168
+ return crawled_pages
169
+
170
+ def save_crawl_results(
171
+ self,
172
+ pages: List[CrawledPage],
173
+ output_dir: Path,
174
+ create_index: bool = True,
175
+ include_attachments: bool = False,
176
+ include_comments: bool = True,
177
+ ) -> None:
178
+ """
179
+ Save crawled pages to disk.
180
+
181
+ Args:
182
+ pages: List of crawled pages
183
+ output_dir: Directory to save pages to
184
+ create_index: Whether to create an index file
185
+ include_attachments: Whether to download and save page attachments
186
+ include_comments: Whether to fetch and save footer and inline comments
187
+ """
188
+ output_dir.mkdir(parents=True, exist_ok=True)
189
+
190
+ # Save each page
191
+ for page in pages:
192
+ page_dir = output_dir / f"{page.metadata.id}_{self._sanitize_filename(page.metadata.title)}"
193
+ page_dir.mkdir(exist_ok=True)
194
+
195
+ # Save metadata
196
+ metadata_file = page_dir / "metadata.json"
197
+ with open(metadata_file, 'w', encoding='utf-8') as f:
198
+ json.dump(page.metadata.__dict__, f, indent=2, ensure_ascii=False)
199
+
200
+ # Save ADF content
201
+ adf_file = page_dir / "content.adf.json"
202
+ with open(adf_file, 'w', encoding='utf-8') as f:
203
+ json.dump(page.adf_content, f, indent=2, ensure_ascii=False)
204
+
205
+ # Save storage format if available
206
+ if page.storage_content:
207
+ storage_file = page_dir / "content.storage.html"
208
+ with open(storage_file, 'w', encoding='utf-8') as f:
209
+ f.write(page.storage_content)
210
+
211
+ # Download attachments if requested
212
+ if include_attachments:
213
+ self._save_attachments(page.metadata.id, page_dir)
214
+
215
+ # Fetch and save comments if requested and client is available
216
+ if include_comments and self.comment_client:
217
+ self._save_comments(page.metadata.id, page_dir)
218
+
219
+ # Create index file
220
+ if create_index:
221
+ index_data = [page.to_dict() for page in pages]
222
+ index_file = output_dir / "index.json"
223
+ with open(index_file, 'w', encoding='utf-8') as f:
224
+ json.dump(index_data, f, indent=2, ensure_ascii=False)
225
+
226
+ self.logger.info(f"Saved {len(pages)} pages to {output_dir}")
227
+
228
+ def _save_comments(self, page_id: str, page_dir: Path) -> None:
229
+ """
230
+ Fetch and save all comments for a page.
231
+
232
+ Saves footer comments, inline comments, and a human-readable summary:
233
+ - comments.footer.json: Raw footer comments from v2 API
234
+ - comments.inline.json: Raw inline comments from v2 API (includes anchor text)
235
+ - comments.md: Human-readable Markdown summary of all comments
236
+
237
+ Args:
238
+ page_id: Confluence page ID
239
+ page_dir: Directory where page files are saved
240
+ """
241
+ footer_data = {}
242
+ inline_data = {}
243
+
244
+ try:
245
+ footer_data = self.comment_client.get_footer_comments(page_id)
246
+ footer_count = len(footer_data.get("results", []))
247
+ if footer_count:
248
+ footer_file = page_dir / "comments.footer.json"
249
+ with open(footer_file, 'w', encoding='utf-8') as f:
250
+ json.dump(footer_data, f, indent=2, ensure_ascii=False)
251
+ self.logger.info(f"Saved {footer_count} footer comments for page {page_id}")
252
+ except Exception as e:
253
+ self.logger.warning(f"Failed to fetch footer comments for page {page_id}: {e}")
254
+
255
+ try:
256
+ inline_data = self.comment_client.get_inline_comments(page_id)
257
+ inline_count = len(inline_data.get("results", []))
258
+ if inline_count:
259
+ inline_file = page_dir / "comments.inline.json"
260
+ with open(inline_file, 'w', encoding='utf-8') as f:
261
+ json.dump(inline_data, f, indent=2, ensure_ascii=False)
262
+ self.logger.info(f"Saved {inline_count} inline comments for page {page_id}")
263
+ except Exception as e:
264
+ self.logger.warning(f"Failed to fetch inline comments for page {page_id}: {e}")
265
+
266
+ # Write human-readable summary if any comments exist
267
+ footer_results = footer_data.get("results", [])
268
+ inline_results = inline_data.get("results", [])
269
+
270
+ if not footer_results and not inline_results:
271
+ return
272
+
273
+ lines = ["# Comments\n"]
274
+
275
+ if footer_results:
276
+ lines.append(f"## Footer Comments ({len(footer_results)})\n")
277
+ for comment in footer_results:
278
+ lines.append(self._format_comment_md(comment, comment_type="footer"))
279
+
280
+ if inline_results:
281
+ lines.append(f"## Inline Comments ({len(inline_results)})\n")
282
+ for comment in inline_results:
283
+ lines.append(self._format_comment_md(comment, comment_type="inline"))
284
+
285
+ comments_md = page_dir / "comments.md"
286
+ with open(comments_md, 'w', encoding='utf-8') as f:
287
+ f.write("\n".join(lines))
288
+
289
+ def _format_comment_md(self, comment: Dict[str, Any], comment_type: str = "footer") -> str:
290
+ """
291
+ Format a v2 API comment as a Markdown block.
292
+
293
+ Args:
294
+ comment: Comment dict from v2 API response
295
+ comment_type: 'footer' or 'inline'
296
+
297
+ Returns:
298
+ Markdown-formatted string
299
+ """
300
+ comment_id = comment.get("id", "unknown")
301
+ version = comment.get("version", {})
302
+ author_id = version.get("authorId", "unknown")
303
+ created_at = version.get("createdAt", "")
304
+
305
+ # Extract body text (storage format is HTML-ish; strip tags for readability)
306
+ body_storage = comment.get("body", {}).get("storage", {})
307
+ body_html = body_storage.get("value", "") if isinstance(body_storage, dict) else ""
308
+ body_text = re.sub(r'<[^>]+>', '', body_html).strip()
309
+
310
+ lines = [f"### Comment {comment_id}"]
311
+ lines.append(f"- **Author**: {author_id}")
312
+ lines.append(f"- **Date**: {created_at}")
313
+ lines.append(f"- **Type**: {comment_type}")
314
+
315
+ if comment_type == "inline":
316
+ props = comment.get("properties", {})
317
+ selection = props.get("inlineOriginalSelection", "")
318
+ if selection:
319
+ lines.append(f"- **Highlighted**: `{selection[:120]}`")
320
+ resolution = comment.get("resolutionStatus", "")
321
+ if resolution:
322
+ lines.append(f"- **Resolution**: {resolution}")
323
+
324
+ if body_text:
325
+ lines.append(f"\n{body_text}")
326
+
327
+ return "\n".join(lines) + "\n"
328
+
329
+ def _save_attachments(self, page_id: str, page_dir: Path) -> None:
330
+ """
331
+ Download and save all attachments for a page.
332
+
333
+ Creates an attachments/ subdirectory containing each attachment file
334
+ and an attachments.json manifest.
335
+
336
+ Args:
337
+ page_id: Confluence page ID
338
+ page_dir: Directory where page files are saved
339
+ """
340
+ attachments = self._get_page_attachments(page_id)
341
+ if not attachments:
342
+ return
343
+
344
+ attachments_dir = page_dir / "attachments"
345
+ attachments_dir.mkdir(exist_ok=True)
346
+
347
+ manifest = []
348
+ for attachment in attachments:
349
+ filename = attachment.get("title", attachment.get("id", "unknown"))
350
+ download_path = attachment.get("_links", {}).get("download")
351
+ attachment_id = attachment.get("id", "")
352
+
353
+ manifest.append({
354
+ "id": attachment_id,
355
+ "filename": filename,
356
+ "media_type": attachment.get("metadata", {}).get("mediaType", ""),
357
+ "file_size": attachment.get("extensions", {}).get("fileSize"),
358
+ "comment": attachment.get("metadata", {}).get("comment", ""),
359
+ })
360
+
361
+ if not download_path:
362
+ self.logger.warning(f"No download link for attachment '{filename}' on page {page_id}")
363
+ continue
364
+
365
+ try:
366
+ content = self._download_attachment_content(download_path)
367
+ safe_filename = self._sanitize_filename(filename)
368
+ out_path = attachments_dir / safe_filename
369
+ # Avoid collisions if sanitization produces duplicate names
370
+ if out_path.exists() and out_path.read_bytes() != content:
371
+ stem = out_path.stem
372
+ suffix = out_path.suffix
373
+ out_path = attachments_dir / f"{stem}_{attachment_id}{suffix}"
374
+ out_path.write_bytes(content)
375
+ self.logger.info(f"Saved attachment: {safe_filename} ({len(content):,} bytes)")
376
+ except Exception as e:
377
+ self.logger.error(f"Failed to download attachment '{filename}': {e}")
378
+
379
+ manifest_file = page_dir / "attachments.json"
380
+ with open(manifest_file, 'w', encoding='utf-8') as f:
381
+ json.dump(manifest, f, indent=2, ensure_ascii=False)
382
+
383
+ def _get_page_attachments(self, page_id: str) -> List[Dict[str, Any]]:
384
+ """
385
+ Fetch the list of attachments for a page.
386
+
387
+ Args:
388
+ page_id: Confluence page ID
389
+
390
+ Returns:
391
+ List of attachment metadata dicts from the Confluence API
392
+ """
393
+ attachments = []
394
+ start = 0
395
+ limit = 100
396
+
397
+ while True:
398
+ params = {"limit": limit, "start": start, "expand": "metadata,extensions"}
399
+ response = self.client.session.get(
400
+ f"{self.client.rest_api_url}/content/{page_id}/child/attachment",
401
+ params=params,
402
+ )
403
+ response.raise_for_status()
404
+ data = response.json()
405
+
406
+ results = data.get("results", [])
407
+ attachments.extend(results)
408
+
409
+ if len(results) < limit:
410
+ break
411
+ start += limit
412
+
413
+ return attachments
414
+
415
+ def _download_attachment_content(self, download_path: str) -> bytes:
416
+ """
417
+ Download the binary content of an attachment.
418
+
419
+ Args:
420
+ download_path: Relative download path from the Confluence API response
421
+
422
+ Returns:
423
+ Binary content of the attachment
424
+ """
425
+ # download_path is relative to the wiki base (e.g. /wiki/download/attachments/...)
426
+ wiki_base = self.client.rest_api_url.replace("/rest/api", "")
427
+ url = f"{wiki_base}{download_path}"
428
+ response = self.client.session.get(url)
429
+ response.raise_for_status()
430
+ return response.content
431
+
432
+ def _crawl_page(self, page_id: str) -> CrawledPage:
433
+ """
434
+ Crawl a single page and extract its content.
435
+
436
+ Args:
437
+ page_id: Confluence page ID
438
+
439
+ Returns:
440
+ Crawled page with metadata and content
441
+ """
442
+ # Request page with all necessary expansions
443
+ params = {
444
+ 'expand': 'body.atlas_doc_format,body.storage,version,space,history,metadata.labels'
445
+ }
446
+
447
+ response = self.client.session.get(
448
+ f"{self.client.rest_api_url}/content/{page_id}",
449
+ params=params,
450
+ )
451
+ response.raise_for_status()
452
+ data = response.json()
453
+
454
+ # Extract metadata
455
+ metadata = self._extract_metadata(data)
456
+
457
+ # Extract ADF content
458
+ adf_content = {}
459
+ if 'body' in data and 'atlas_doc_format' in data['body']:
460
+ adf_content = data['body']['atlas_doc_format'].get('value', {})
461
+ if isinstance(adf_content, str):
462
+ adf_content = json.loads(adf_content)
463
+
464
+ # Extract storage format
465
+ storage_content = None
466
+ if 'body' in data and 'storage' in data['body']:
467
+ storage_content = data['body']['storage'].get('value')
468
+
469
+ return CrawledPage(
470
+ metadata=metadata,
471
+ adf_content=adf_content,
472
+ storage_content=storage_content,
473
+ )
474
+
475
+ def _extract_metadata(self, page_data: Dict[str, Any]) -> PageMetadata:
476
+ """Extract page metadata from API response."""
477
+ version_info = page_data.get('version', {})
478
+ history_info = page_data.get('history', {})
479
+ space_info = page_data.get('space', {})
480
+
481
+ # Extract labels
482
+ labels = []
483
+ if 'metadata' in page_data and 'labels' in page_data['metadata']:
484
+ labels = [
485
+ label.get('name', '')
486
+ for label in page_data['metadata']['labels'].get('results', [])
487
+ ]
488
+
489
+ return PageMetadata(
490
+ id=page_data['id'],
491
+ title=page_data['title'],
492
+ space_key=space_info.get('key', ''),
493
+ version=version_info.get('number', 0),
494
+ status=page_data.get('status', 'current'),
495
+ created_date=history_info.get('createdDate', ''),
496
+ modified_date=version_info.get('when', ''),
497
+ creator=history_info.get('createdBy', {}).get('displayName'),
498
+ last_modifier=version_info.get('by', {}).get('displayName'),
499
+ parent_id=page_data.get('ancestors', [{}])[-1].get('id') if page_data.get('ancestors') else None,
500
+ url=page_data.get('_links', {}).get('webui'),
501
+ labels=labels,
502
+ )
503
+
504
+ def _get_all_pages_in_space(
505
+ self,
506
+ space_key: str,
507
+ include_archived: bool = False,
508
+ ) -> List[Dict[str, Any]]:
509
+ """
510
+ Get all pages in a space using pagination.
511
+
512
+ Args:
513
+ space_key: Confluence space key
514
+ include_archived: Whether to include archived pages
515
+
516
+ Returns:
517
+ List of page information dictionaries
518
+ """
519
+ pages = []
520
+ start = 0
521
+ limit = 100
522
+
523
+ while True:
524
+ params = {
525
+ 'spaceKey': space_key,
526
+ 'limit': limit,
527
+ 'start': start,
528
+ 'expand': 'version,space',
529
+ }
530
+
531
+ if include_archived:
532
+ params['status'] = 'current,archived'
533
+
534
+ response = self.client.session.get(
535
+ f"{self.client.rest_api_url}/content",
536
+ params=params,
537
+ )
538
+ response.raise_for_status()
539
+ data = response.json()
540
+
541
+ results = data.get('results', [])
542
+ pages.extend(results)
543
+
544
+ # Check if there are more pages
545
+ if len(results) < limit:
546
+ break
547
+
548
+ start += limit
549
+
550
+ return pages
551
+
552
+ def _get_child_pages(self, page_id: str) -> List[Dict[str, Any]]:
553
+ """
554
+ Get child pages of a parent page.
555
+
556
+ Args:
557
+ page_id: Parent page ID
558
+
559
+ Returns:
560
+ List of child page information
561
+ """
562
+ params = {
563
+ 'expand': 'version',
564
+ }
565
+
566
+ response = self.client.session.get(
567
+ f"{self.client.rest_api_url}/content/{page_id}/child/page",
568
+ params=params,
569
+ )
570
+ response.raise_for_status()
571
+ data = response.json()
572
+
573
+ return data.get('results', [])
574
+
575
+ @staticmethod
576
+ def _sanitize_filename(filename: str) -> str:
577
+ """Sanitize a string for use as a filename."""
578
+ # Replace invalid characters
579
+ invalid_chars = '<>:"/\\|?*'
580
+ for char in invalid_chars:
581
+ filename = filename.replace(char, '_')
582
+
583
+ # Limit length
584
+ if len(filename) > 100:
585
+ filename = filename[:100]
586
+
587
+ return filename.strip()