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.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- 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()
|