confluence-knowledge 0.3.1__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.
- confluence_knowledge/__init__.py +3 -0
- confluence_knowledge/auth.py +83 -0
- confluence_knowledge/cli.py +180 -0
- confluence_knowledge/client.py +152 -0
- confluence_knowledge/crawl.py +236 -0
- confluence_knowledge/extract.py +118 -0
- confluence_knowledge/index.py +193 -0
- confluence_knowledge/models.lock.json +15 -0
- confluence_knowledge/registration.py +134 -0
- confluence_knowledge/retrieval.py +207 -0
- confluence_knowledge/server.py +341 -0
- confluence_knowledge/settings.py +156 -0
- confluence_knowledge-0.3.1.dist-info/METADATA +77 -0
- confluence_knowledge-0.3.1.dist-info/RECORD +17 -0
- confluence_knowledge-0.3.1.dist-info/WHEEL +5 -0
- confluence_knowledge-0.3.1.dist-info/entry_points.txt +2 -0
- confluence_knowledge-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Credential storage: OS keyring → env var → protected file (explicit choice).
|
|
2
|
+
|
|
3
|
+
Tokens are never accepted via chat, MCP tool arguments, or command-line flags —
|
|
4
|
+
the setup wizard prompts with getpass in the user's own terminal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .settings import APP_NAME, AUTH_FILE_NAME, ensure_gitignore, reject_unsafe_path
|
|
15
|
+
|
|
16
|
+
SERVICE = APP_NAME
|
|
17
|
+
ENV_TOKEN = "CONF_KNOWLEDGE_TOKEN"
|
|
18
|
+
ENV_EMAIL = "CONF_KNOWLEDGE_EMAIL"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _keyring_store(ref: str, secret: str) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
import keyring
|
|
24
|
+
from keyring.backends.fail import Keyring as FailBackend
|
|
25
|
+
if isinstance(keyring.get_keyring(), FailBackend):
|
|
26
|
+
return False
|
|
27
|
+
keyring.set_password(SERVICE, ref, secret)
|
|
28
|
+
return True
|
|
29
|
+
except Exception: # noqa: BLE001 — keyring backends fail in many ways; fallback is intentional
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _keyring_get(ref: str) -> str | None:
|
|
34
|
+
try:
|
|
35
|
+
import keyring
|
|
36
|
+
return keyring.get_password(SERVICE, ref)
|
|
37
|
+
except Exception: # noqa: BLE001 — unavailable keyring → try next provider
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def store_credential(workdir: Path, home: Path, ref: str, token: str) -> str:
|
|
42
|
+
"""Persist token: keyring preferred, protected file as explicit fallback.
|
|
43
|
+
|
|
44
|
+
Returns a human description of where it went (never the token).
|
|
45
|
+
"""
|
|
46
|
+
if _keyring_store(ref, token):
|
|
47
|
+
return "OS credential store"
|
|
48
|
+
# file fallback — gitignore BEFORE writing
|
|
49
|
+
ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
|
|
50
|
+
p = reject_unsafe_path(workdir / AUTH_FILE_NAME)
|
|
51
|
+
data = json.loads(p.read_text()) if p.exists() else {}
|
|
52
|
+
data[ref] = token
|
|
53
|
+
tmp = p.with_suffix(".tmp")
|
|
54
|
+
tmp.write_text(json.dumps(data))
|
|
55
|
+
os.replace(tmp, p)
|
|
56
|
+
if os.name != "nt":
|
|
57
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
|
|
58
|
+
return f"protected file {p} (chmod 600)"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_credential(workdir: Path, ref: str) -> str | None:
|
|
62
|
+
"""Lookup order: env var (CI/scripts) → keyring → protected file."""
|
|
63
|
+
t = os.environ.get(ENV_TOKEN)
|
|
64
|
+
if t:
|
|
65
|
+
return t
|
|
66
|
+
t = _keyring_get(ref)
|
|
67
|
+
if t:
|
|
68
|
+
return t
|
|
69
|
+
p = workdir / AUTH_FILE_NAME
|
|
70
|
+
if p.exists():
|
|
71
|
+
data = json.loads(p.read_text())
|
|
72
|
+
return data.get(ref)
|
|
73
|
+
p2 = Path.home() / ".confluence-knowledge" / AUTH_FILE_NAME
|
|
74
|
+
if p2.exists():
|
|
75
|
+
return json.loads(p2.read_text()).get(ref)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def redact(text: str, secrets: list[str]) -> str:
|
|
80
|
+
for s in secrets:
|
|
81
|
+
if s:
|
|
82
|
+
text = text.replace(s, "***")
|
|
83
|
+
return text
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""confluence-knowledge CLI: setup / doctor / prepare-models / serve / clean.
|
|
2
|
+
|
|
3
|
+
The setup wizard prompts for tokens via getpass in the user's own terminal —
|
|
4
|
+
tokens never travel through chat or command-line arguments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import getpass
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .auth import store_credential
|
|
17
|
+
from .client import AccessError, make_client
|
|
18
|
+
from .retrieval import prepare_models
|
|
19
|
+
from .settings import (
|
|
20
|
+
AUTH_FILE_NAME,
|
|
21
|
+
INDEX_DIR_NAME,
|
|
22
|
+
SetupError,
|
|
23
|
+
SiteProfile,
|
|
24
|
+
default_home,
|
|
25
|
+
ensure_gitignore,
|
|
26
|
+
resolve_workdir,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _wizard() -> int:
|
|
31
|
+
print("confluence-knowledge setup\n")
|
|
32
|
+
env = ""
|
|
33
|
+
while env not in ("1", "2"):
|
|
34
|
+
env = input("Confluence environment — [1] Cloud (atlassian.net) "
|
|
35
|
+
"or [2] Data Center (corporate URL)? ").strip()
|
|
36
|
+
env = "cloud" if env == "1" else "datacenter"
|
|
37
|
+
|
|
38
|
+
if env == "cloud":
|
|
39
|
+
print("\nCreate a token: sign in to https://id.atlassian.com → "
|
|
40
|
+
"Security → Create and manage API tokens → Create API token "
|
|
41
|
+
"(choose Confluence read scopes if scoped tokens are offered).")
|
|
42
|
+
site = input("\nSite URL (e.g. https://acme.atlassian.net): ").strip()
|
|
43
|
+
email = input("Atlassian account email: ").strip()
|
|
44
|
+
else:
|
|
45
|
+
print("\nCreate a token: Confluence → your profile picture → Settings → "
|
|
46
|
+
"Personal Access Tokens → Create token (read access is enough).")
|
|
47
|
+
site = input("\nSite URL incl. context path (e.g. https://conf.acme.corp/confluence): ").strip()
|
|
48
|
+
email = None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
base = SiteProfile.canonical_base(env, site)
|
|
52
|
+
except SetupError as e:
|
|
53
|
+
print(f"error: {e}", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
workdir = resolve_workdir()
|
|
57
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
home = default_home()
|
|
59
|
+
# gitignore protections BEFORE any secret/cache write
|
|
60
|
+
try:
|
|
61
|
+
ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
|
|
62
|
+
except SetupError as e:
|
|
63
|
+
print(f"error: {e}", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
ref = f"{env}:{base}:{email or '-'}"
|
|
67
|
+
token = getpass.getpass("Token (input hidden): ")
|
|
68
|
+
if not token:
|
|
69
|
+
print("error: empty token", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
where = store_credential(workdir, home, ref, token)
|
|
73
|
+
SiteProfile(env, base, email, ref).save(home)
|
|
74
|
+
|
|
75
|
+
# heartbeat
|
|
76
|
+
try:
|
|
77
|
+
c = make_client(SiteProfile(env, base, email, ref), token)
|
|
78
|
+
ep = "wiki/rest/api/user/current" if env == "cloud" else "rest/api/user/current"
|
|
79
|
+
who = c.get(ep).json()
|
|
80
|
+
print(f"\nconnected as: {who.get('displayName') or who.get('username') or 'OK'}")
|
|
81
|
+
except Exception as e: # noqa: BLE001 — any connect failure (auth/network/TLS) exits non-zero
|
|
82
|
+
print(f"\nconnection check failed: {e}", file=sys.stderr)
|
|
83
|
+
return 3
|
|
84
|
+
|
|
85
|
+
print(f"credential stored: {where}")
|
|
86
|
+
print(f"profile: {home / 'profile.json'}")
|
|
87
|
+
print("\nNext: `confluence-knowledge prepare-models` to fetch the local "
|
|
88
|
+
"models (Apache-2.0), then point your IDE's MCP config at:\n"
|
|
89
|
+
f" command: {sys.executable}\n"
|
|
90
|
+
f" args: [\"-m\", \"confluence_knowledge.server\"]\n"
|
|
91
|
+
f" env: CONF_KNOWLEDGE_WORKDIR={workdir}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _doctor() -> int:
|
|
96
|
+
from .server import status
|
|
97
|
+
try:
|
|
98
|
+
s = status()
|
|
99
|
+
print(json.dumps(s, indent=2))
|
|
100
|
+
if s.get("configured"):
|
|
101
|
+
from .server import _client_for # internal but fine in CLI
|
|
102
|
+
profile = SiteProfile.load(default_home())
|
|
103
|
+
try:
|
|
104
|
+
c = _client_for(profile)
|
|
105
|
+
ep = ("wiki/rest/api/user/current" if profile.env == "cloud"
|
|
106
|
+
else "rest/api/user/current")
|
|
107
|
+
who = c.get(ep).json()
|
|
108
|
+
print("heartbeat: OK —",
|
|
109
|
+
who.get("displayName") or who.get("username"))
|
|
110
|
+
except (AccessError, SetupError) as e:
|
|
111
|
+
print(f"heartbeat: FAILED — {e}", file=sys.stderr)
|
|
112
|
+
return 3
|
|
113
|
+
return 0
|
|
114
|
+
except SetupError as e:
|
|
115
|
+
print(f"setup error: {e}", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
ap = argparse.ArgumentParser(prog="confluence-knowledge")
|
|
121
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
122
|
+
sub.add_parser("setup", help="guided credential + config setup")
|
|
123
|
+
sub.add_parser("doctor", help="verify config, credentials, models")
|
|
124
|
+
sub.add_parser("prepare-models", help="download the pinned local models")
|
|
125
|
+
reg = sub.add_parser(
|
|
126
|
+
"register",
|
|
127
|
+
help="write this server into the IDE's MCP config (no manual paste)")
|
|
128
|
+
reg.add_argument("--ide", default="auto",
|
|
129
|
+
choices=["auto", "all", "vscode", "jetbrains"],
|
|
130
|
+
help="which config files to write (default: all that apply)")
|
|
131
|
+
reg.add_argument("--workspace", type=Path, default=None,
|
|
132
|
+
help="also write <workspace>/.vscode/mcp.json")
|
|
133
|
+
reg.add_argument("--remove", action="store_true",
|
|
134
|
+
help="remove our entry instead of adding it")
|
|
135
|
+
sub.add_parser("serve", help="run the stdio MCP server")
|
|
136
|
+
clean = sub.add_parser("clean", help="delete the local index for this repo")
|
|
137
|
+
clean.add_argument("--yes", action="store_true")
|
|
138
|
+
args = ap.parse_args()
|
|
139
|
+
|
|
140
|
+
if args.cmd == "setup":
|
|
141
|
+
sys.exit(_wizard())
|
|
142
|
+
if args.cmd == "doctor":
|
|
143
|
+
sys.exit(_doctor())
|
|
144
|
+
if args.cmd == "prepare-models":
|
|
145
|
+
for line in prepare_models(default_home()):
|
|
146
|
+
print(line)
|
|
147
|
+
return
|
|
148
|
+
if args.cmd == "register":
|
|
149
|
+
from .registration import register
|
|
150
|
+
try:
|
|
151
|
+
wd = (Path(os.environ["CONF_KNOWLEDGE_WORKDIR"]).expanduser().resolve()
|
|
152
|
+
if os.environ.get("CONF_KNOWLEDGE_WORKDIR") else None)
|
|
153
|
+
results = register(ide=args.ide, workspace=args.workspace,
|
|
154
|
+
workdir=wd, remove=args.remove)
|
|
155
|
+
except SetupError as e:
|
|
156
|
+
print(f"error: {e}", file=sys.stderr)
|
|
157
|
+
sys.exit(2)
|
|
158
|
+
for path, action in results:
|
|
159
|
+
print(f"{action}: {path}")
|
|
160
|
+
if not args.remove:
|
|
161
|
+
print("\nReload your IDE so Copilot picks up the server, "
|
|
162
|
+
"then re-ask the agent.")
|
|
163
|
+
return
|
|
164
|
+
if args.cmd == "serve":
|
|
165
|
+
from .server import main as serve
|
|
166
|
+
serve()
|
|
167
|
+
return
|
|
168
|
+
if args.cmd == "clean":
|
|
169
|
+
import shutil
|
|
170
|
+
|
|
171
|
+
target = resolve_workdir() / INDEX_DIR_NAME
|
|
172
|
+
if target.exists() and (args.yes or input(
|
|
173
|
+
f"delete {target}? [y/N] ").strip().lower() == "y"):
|
|
174
|
+
shutil.rmtree(target)
|
|
175
|
+
print("index deleted")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""GET-only Confluence REST client.
|
|
2
|
+
|
|
3
|
+
Every transport call passes through request(): a non-GET method is rejected
|
|
4
|
+
with SecurityException before any network I/O, and paths are validated against
|
|
5
|
+
a per-adapter endpoint allowlist. No convenience method can bypass this.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.parse import urljoin, urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SecurityException(RuntimeError):
|
|
18
|
+
"""Raised when a write/delete/mutation HTTP operation is attempted."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AccessError(RuntimeError):
|
|
22
|
+
"""Authentication, permission, or reachability failure (actionable)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EndpointRule:
|
|
27
|
+
pattern: re.Pattern # matched against the API path
|
|
28
|
+
purpose: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfluenceClient:
|
|
32
|
+
"""GET-only client bound to one site base URL and one route allowlist."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
headers: dict[str, str],
|
|
38
|
+
rules: list[EndpointRule],
|
|
39
|
+
timeout: float = 20.0,
|
|
40
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
41
|
+
session: httpx.Client | None = None,
|
|
42
|
+
):
|
|
43
|
+
u = urlparse(base_url)
|
|
44
|
+
if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
|
|
45
|
+
raise AccessError("Confluence base URL must be https")
|
|
46
|
+
self._base = base_url.rstrip("/") + "/"
|
|
47
|
+
self._base_origin = f"{u.scheme}://{u.netloc}"
|
|
48
|
+
self._base_path = u.path.rstrip("/")
|
|
49
|
+
self._rules = rules
|
|
50
|
+
self._http = session or httpx.Client(
|
|
51
|
+
headers={"Accept": "application/json", **headers},
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
follow_redirects=False, # never carry credentials through redirects
|
|
54
|
+
)
|
|
55
|
+
self._max_bytes = max_bytes
|
|
56
|
+
|
|
57
|
+
# ---- the GET-only wrapper ----
|
|
58
|
+
|
|
59
|
+
def request(self, method: str, path: str, params: dict | None = None) -> httpx.Response:
|
|
60
|
+
if method.upper() != "GET":
|
|
61
|
+
raise SecurityException(
|
|
62
|
+
"Error: This agent is strictly read-only. "
|
|
63
|
+
"Write/Delete operations are forbidden."
|
|
64
|
+
)
|
|
65
|
+
api_path = self._validate_path(path)
|
|
66
|
+
resp = self._http.get(urljoin(self._base, api_path.lstrip("/")), params=params)
|
|
67
|
+
if resp.status_code >= 400:
|
|
68
|
+
raise AccessError(self._describe(resp))
|
|
69
|
+
if resp.status_code >= 300:
|
|
70
|
+
raise AccessError(
|
|
71
|
+
f"unexpected redirect ({resp.status_code}) — check the base URL/context path"
|
|
72
|
+
)
|
|
73
|
+
if len(resp.content) > self._max_bytes:
|
|
74
|
+
raise AccessError(f"response exceeds {self._max_bytes} bytes — refusing to buffer")
|
|
75
|
+
return resp
|
|
76
|
+
|
|
77
|
+
def get(self, path: str, params: dict | None = None) -> httpx.Response:
|
|
78
|
+
return self.request("GET", path, params)
|
|
79
|
+
|
|
80
|
+
# ---- validation ----
|
|
81
|
+
|
|
82
|
+
def _validate_path(self, path: str) -> str:
|
|
83
|
+
"""Normalize to an API-relative path; enforce site + endpoint bounds."""
|
|
84
|
+
u = urlparse(path)
|
|
85
|
+
if u.scheme or u.netloc:
|
|
86
|
+
# absolute URL (e.g. pagination next-link) must stay inside
|
|
87
|
+
# the configured origin + API base path
|
|
88
|
+
full_base = self._base_origin + self._base_path
|
|
89
|
+
full = f"{u.scheme}://{u.netloc}{u.path}"
|
|
90
|
+
if not (full == full_base or full.startswith(full_base + "/")):
|
|
91
|
+
raise SecurityException(
|
|
92
|
+
"Error: request escapes the configured Confluence site — refused."
|
|
93
|
+
)
|
|
94
|
+
rel = u.path[len(self._base_path):].lstrip("/")
|
|
95
|
+
elif path.startswith("/"):
|
|
96
|
+
# site-relative path must live under the API base path
|
|
97
|
+
if self._base_path and not path.startswith(self._base_path + "/"):
|
|
98
|
+
raise SecurityException(
|
|
99
|
+
"Error: request escapes the configured Confluence site — refused."
|
|
100
|
+
)
|
|
101
|
+
rel = path[len(self._base_path):].lstrip("/")
|
|
102
|
+
else:
|
|
103
|
+
rel = path
|
|
104
|
+
if ".." in rel:
|
|
105
|
+
raise SecurityException("Error: path traversal rejected.")
|
|
106
|
+
if not any(r.pattern.match(rel) for r in self._rules):
|
|
107
|
+
raise SecurityException(
|
|
108
|
+
"Error: This agent is strictly read-only. "
|
|
109
|
+
f"Endpoint not on the allowed GET list: {rel}"
|
|
110
|
+
)
|
|
111
|
+
return rel
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _describe(resp: httpx.Response) -> str:
|
|
115
|
+
s = resp.status_code
|
|
116
|
+
return {
|
|
117
|
+
401: "authentication failed (401) — check the token and account",
|
|
118
|
+
403: "permission denied (403) — your account can't view this content",
|
|
119
|
+
404: "not found (404) — check the page ID/space and base URL",
|
|
120
|
+
429: "rate limited (429) — retry after the server's Retry-After",
|
|
121
|
+
}.get(s, f"HTTP {s} from Confluence")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- adapters ----
|
|
125
|
+
|
|
126
|
+
RULES_V2 = [
|
|
127
|
+
EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/?$"), "page body"),
|
|
128
|
+
EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/direct-children/?$"), "children"),
|
|
129
|
+
EndpointRule(re.compile(r"wiki/api/v2/folders/\d+/direct-children/?$"), "folder children"),
|
|
130
|
+
EndpointRule(re.compile(r"wiki/rest/api/user/current/?$"), "identity"),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
RULES_DC = [
|
|
134
|
+
EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/?$"), "page body"),
|
|
135
|
+
EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/child/page/?$"), "children"),
|
|
136
|
+
EndpointRule(re.compile(r"(?:confluence/)?rest/api/user/current/?$"), "identity"),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def make_client(profile, token: str, cloud_id: str | None = None) -> ConfluenceClient:
|
|
140
|
+
"""Build a client for the configured profile. Token only in headers."""
|
|
141
|
+
import base64
|
|
142
|
+
|
|
143
|
+
if profile.env == "cloud":
|
|
144
|
+
auth = base64.b64encode(f"{profile.email}:{token}".encode()).decode()
|
|
145
|
+
# scoped tokens go through the Atlassian gateway bound to one cloudId;
|
|
146
|
+
# the same v2 route shapes apply relative to that base path
|
|
147
|
+
base = (f"https://api.atlassian.com/ex/confluence/{cloud_id}"
|
|
148
|
+
if cloud_id else profile.base_url)
|
|
149
|
+
return ConfluenceClient(base, {"Authorization": f"Basic {auth}"}, RULES_V2)
|
|
150
|
+
return ConfluenceClient(
|
|
151
|
+
profile.base_url, {"Authorization": f"Bearer {token}"}, RULES_DC
|
|
152
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Durable, resumable breadth-first crawl of one page subtree.
|
|
2
|
+
|
|
3
|
+
Queue and progress live in the index DB — a crawl can be interrupted at any
|
|
4
|
+
point and resumed by job ID. Bodies are fetched only for pages admitted to the
|
|
5
|
+
root's descendant graph.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from .client import AccessError, ConfluenceClient
|
|
18
|
+
from .extract import extract_chunks
|
|
19
|
+
from .index import Index
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CrawlJob:
|
|
24
|
+
job_id: str
|
|
25
|
+
root_page: str
|
|
26
|
+
queue: list[str] = field(default_factory=list)
|
|
27
|
+
visited: set[str] = field(default_factory=set)
|
|
28
|
+
errors: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fetch_children_v2(client: ConfluenceClient, page_id: str) -> list[tuple[str, str]]:
|
|
32
|
+
"""(child_id, child_type) — pages and folders both; folders are traversed
|
|
33
|
+
but never indexed as content."""
|
|
34
|
+
out, cursor = [], None
|
|
35
|
+
while True:
|
|
36
|
+
params = {"limit": 250}
|
|
37
|
+
if cursor:
|
|
38
|
+
params["cursor"] = cursor
|
|
39
|
+
r = client.get(f"wiki/api/v2/pages/{page_id}/direct-children", params)
|
|
40
|
+
d = r.json()
|
|
41
|
+
for ch in d.get("results", []):
|
|
42
|
+
out.append((str(ch["id"]), ch.get("type", "page")))
|
|
43
|
+
nxt = (d.get("_links") or {}).get("next")
|
|
44
|
+
if not nxt:
|
|
45
|
+
break
|
|
46
|
+
cursor = nxt.split("cursor=")[-1].split("&")[0]
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fetch_folder_children_v2(client: ConfluenceClient, folder_id: str) -> list[tuple[str, str]]:
|
|
51
|
+
out, cursor = [], None
|
|
52
|
+
while True:
|
|
53
|
+
params = {"limit": 250}
|
|
54
|
+
if cursor:
|
|
55
|
+
params["cursor"] = cursor
|
|
56
|
+
r = client.get(f"wiki/api/v2/folders/{folder_id}/direct-children", params)
|
|
57
|
+
d = r.json()
|
|
58
|
+
for ch in d.get("results", []):
|
|
59
|
+
out.append((str(ch["id"]), ch.get("type", "page")))
|
|
60
|
+
nxt = (d.get("_links") or {}).get("next")
|
|
61
|
+
if not nxt:
|
|
62
|
+
break
|
|
63
|
+
cursor = nxt.split("cursor=")[-1].split("&")[0]
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fetch_children_dc(client: ConfluenceClient, page_id: str) -> list[tuple[str, str]]:
|
|
68
|
+
out, start = [], 0
|
|
69
|
+
while True:
|
|
70
|
+
r = client.get(f"rest/api/content/{page_id}/child/page",
|
|
71
|
+
params={"limit": 200, "start": start})
|
|
72
|
+
d = r.json()
|
|
73
|
+
for ch in d.get("results", []):
|
|
74
|
+
out.append((str(ch["id"]), "page"))
|
|
75
|
+
if d.get("size", 0) < d.get("limit", 200):
|
|
76
|
+
break
|
|
77
|
+
start += d["size"]
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _abs_url(webui: str, base: str | None) -> str:
|
|
82
|
+
"""Join Confluence's relative `_links.webui` onto a base so citations are
|
|
83
|
+
clickable; absolute links pass through unchanged."""
|
|
84
|
+
if not webui or urlparse(webui).scheme or not base:
|
|
85
|
+
return webui
|
|
86
|
+
return base.rstrip("/") + "/" + webui.lstrip("/")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fetch_page_v2(client: ConfluenceClient, page_id: str) -> dict:
|
|
90
|
+
d = client.get(f"wiki/api/v2/pages/{page_id}",
|
|
91
|
+
params={"body-format": "storage"}).json()
|
|
92
|
+
links = d.get("_links") or {}
|
|
93
|
+
return {
|
|
94
|
+
"page_id": str(d["id"]), "title": d.get("title", ""),
|
|
95
|
+
"storage": (d.get("body") or {}).get("storage", {}).get("value", ""),
|
|
96
|
+
"version": str((d.get("version") or {}).get("number", "")),
|
|
97
|
+
"url": _abs_url(links.get("webui", ""), links.get("base")),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fetch_page_dc(client: ConfluenceClient, page_id: str) -> dict:
|
|
102
|
+
d = client.get(f"rest/api/content/{page_id}",
|
|
103
|
+
params={"expand": "body.storage,version,ancestors"}).json()
|
|
104
|
+
links = d.get("_links") or {}
|
|
105
|
+
return {
|
|
106
|
+
"page_id": str(d["id"]), "title": d.get("title", ""),
|
|
107
|
+
"storage": (d.get("body") or {}).get("storage", {}).get("value", ""),
|
|
108
|
+
"version": str((d.get("version") or {}).get("number", "")),
|
|
109
|
+
"url": _abs_url(links.get("webui", ""), links.get("base")),
|
|
110
|
+
"ancestors": [str(a["id"]) for a in d.get("ancestors", [])],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Crawler:
|
|
115
|
+
def __init__(self, client: ConfluenceClient, index: Index, env: str,
|
|
116
|
+
embedder=None, site_url: str | None = None):
|
|
117
|
+
self.client = client
|
|
118
|
+
self.index = index
|
|
119
|
+
self.env = env
|
|
120
|
+
self.embedder = embedder # retrieval.LocalEmbedder or None
|
|
121
|
+
# fallback base for page links when a response carries no _links.base
|
|
122
|
+
if site_url and env == "cloud" and not site_url.rstrip("/").endswith("/wiki"):
|
|
123
|
+
site_url = site_url.rstrip("/") + "/wiki"
|
|
124
|
+
self.site_url = site_url
|
|
125
|
+
|
|
126
|
+
def _children(self, pid: str, is_folder: bool = False):
|
|
127
|
+
if self.env == "cloud":
|
|
128
|
+
return (_fetch_folder_children_v2(self.client, pid) if is_folder
|
|
129
|
+
else _fetch_children_v2(self.client, pid))
|
|
130
|
+
return _fetch_children_dc(self.client, pid)
|
|
131
|
+
|
|
132
|
+
def _page(self, pid: str) -> dict:
|
|
133
|
+
page = (_fetch_page_v2(self.client, pid) if self.env == "cloud"
|
|
134
|
+
else _fetch_page_dc(self.client, pid))
|
|
135
|
+
page["url"] = _abs_url(page["url"], self.site_url)
|
|
136
|
+
return page
|
|
137
|
+
|
|
138
|
+
def start(self, root_page: str) -> str:
|
|
139
|
+
job_id = uuid.uuid4().hex[:12]
|
|
140
|
+
self.index.abandon_running_jobs()
|
|
141
|
+
self.index.create_job(job_id, root_page)
|
|
142
|
+
self.index.save_job(job_id, "running", [f"page:{root_page}"], [])
|
|
143
|
+
return job_id
|
|
144
|
+
|
|
145
|
+
def _checkpoint(self, job_id, queue, errors, seen, levels) -> None:
|
|
146
|
+
"""Persist progress so an interrupted step loses no completed page."""
|
|
147
|
+
with self.index.lock, self.index.db:
|
|
148
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
149
|
+
(f"seen:{job_id}", json.dumps(sorted(seen))))
|
|
150
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
151
|
+
(f"levels:{job_id}", json.dumps(levels)))
|
|
152
|
+
self.index.db.execute(
|
|
153
|
+
"UPDATE jobs SET queue=?, errors=? WHERE job_id=?",
|
|
154
|
+
(json.dumps(queue), json.dumps(errors), job_id))
|
|
155
|
+
|
|
156
|
+
def step(self, job_id: str, batch: int | None = 10,
|
|
157
|
+
budget_s: float | None = None) -> dict:
|
|
158
|
+
"""Process up to `batch` pages (None = whole queue), stopping early
|
|
159
|
+
once `budget_s` seconds have passed. The background worker calls this
|
|
160
|
+
unbounded; a bounded call stays under the IDE's request timeout."""
|
|
161
|
+
deadline = None if budget_s is None else time.monotonic() + budget_s
|
|
162
|
+
job = self.index.job(job_id)
|
|
163
|
+
if job is None:
|
|
164
|
+
raise AccessError(f"unknown job {job_id}")
|
|
165
|
+
if job["state"] != "running":
|
|
166
|
+
return {"job_id": job_id, "state": job["state"],
|
|
167
|
+
"remaining": len(job["queue"]), "errors": job["errors"]}
|
|
168
|
+
|
|
169
|
+
queue: list[str] = job["queue"]
|
|
170
|
+
errors: list[str] = job["errors"]
|
|
171
|
+
seen_raw = self.index.meta_get(f"seen:{job_id}")
|
|
172
|
+
seen: set[str] = set(json.loads(seen_raw)) if seen_raw else set()
|
|
173
|
+
levels_raw = self.index.meta_get(f"levels:{job_id}")
|
|
174
|
+
levels: dict[str, int] = (json.loads(levels_raw) if levels_raw
|
|
175
|
+
else {job["root_page"]: 0})
|
|
176
|
+
visited: set[str] = set()
|
|
177
|
+
|
|
178
|
+
processed = 0
|
|
179
|
+
while queue and (batch is None or processed < batch) and (
|
|
180
|
+
deadline is None or processed == 0 or time.monotonic() < deadline):
|
|
181
|
+
if processed:
|
|
182
|
+
self._checkpoint(job_id, queue, errors, seen, levels)
|
|
183
|
+
entry = queue.pop(0) # "page:123" or "folder:456"
|
|
184
|
+
is_folder = entry.startswith("folder:")
|
|
185
|
+
pid = entry.split(":", 1)[1] if ":" in entry else entry
|
|
186
|
+
if pid in visited or pid in seen:
|
|
187
|
+
continue
|
|
188
|
+
visited.add(pid)
|
|
189
|
+
processed += 1
|
|
190
|
+
if is_folder:
|
|
191
|
+
# traverse folders to reach descendant pages — never indexed
|
|
192
|
+
for cid, ctype in self._children(pid, is_folder=True):
|
|
193
|
+
if cid not in seen:
|
|
194
|
+
queue.append(f"{ctype}:{cid}")
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
page = self._page(pid)
|
|
198
|
+
except AccessError as e:
|
|
199
|
+
errors.append(f"page {pid}: {e}")
|
|
200
|
+
continue
|
|
201
|
+
if pid != job["root_page"]:
|
|
202
|
+
anc = page.get("ancestors") or []
|
|
203
|
+
if anc and job["root_page"] not in anc:
|
|
204
|
+
errors.append(f"page {pid}: outside root subtree — skipped")
|
|
205
|
+
continue
|
|
206
|
+
content_hash = hashlib.sha256(
|
|
207
|
+
page["storage"].encode()).hexdigest()
|
|
208
|
+
chunks, warns = extract_chunks(page["storage"])
|
|
209
|
+
for c in chunks:
|
|
210
|
+
c.chunk_id = f"{pid}:{content_hash[:8]}:{c.ordinal}"
|
|
211
|
+
vecs = None
|
|
212
|
+
if self.embedder is not None and chunks:
|
|
213
|
+
vecs = self.embedder.embed(
|
|
214
|
+
[f"{c.heading_path}\n{c.text}" for c in chunks])
|
|
215
|
+
self.index.upsert_page({
|
|
216
|
+
"page_id": pid, "parent_id": None, "title": page["title"],
|
|
217
|
+
"url": page["url"], "level": levels.get(pid, 0),
|
|
218
|
+
"version": page["version"], "content_hash": content_hash,
|
|
219
|
+
"warnings": warns}, chunks, vecs)
|
|
220
|
+
seen.add(pid)
|
|
221
|
+
for cid, ctype in self._children(pid, is_folder=False):
|
|
222
|
+
if cid not in seen:
|
|
223
|
+
levels.setdefault(cid, levels.get(pid, 0) + 1)
|
|
224
|
+
queue.append(f"{ctype}:{cid}")
|
|
225
|
+
|
|
226
|
+
self._checkpoint(job_id, queue, errors, seen, levels)
|
|
227
|
+
|
|
228
|
+
if queue:
|
|
229
|
+
self.index.save_job(job_id, "running", queue, errors)
|
|
230
|
+
state = "running"
|
|
231
|
+
else:
|
|
232
|
+
self.index.publish(seen)
|
|
233
|
+
self.index.save_job(job_id, "done", [], errors)
|
|
234
|
+
state = "done"
|
|
235
|
+
return {"job_id": job_id, "state": state, "remaining": len(queue),
|
|
236
|
+
"pages_seen": len(seen), "errors": errors}
|