moodle-cli 0.1.0b0__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.
- moodle_cli/__init__.py +3 -0
- moodle_cli/auth.py +74 -0
- moodle_cli/cli.py +142 -0
- moodle_cli/client.py +206 -0
- moodle_cli/config.py +140 -0
- moodle_cli/constants.py +19 -0
- moodle_cli/exceptions.py +17 -0
- moodle_cli/formatter.py +90 -0
- moodle_cli/models.py +85 -0
- moodle_cli/output.py +17 -0
- moodle_cli/parser.py +57 -0
- moodle_cli/scraper.py +186 -0
- moodle_cli-0.1.0b0.dist-info/METADATA +112 -0
- moodle_cli-0.1.0b0.dist-info/RECORD +17 -0
- moodle_cli-0.1.0b0.dist-info/WHEEL +4 -0
- moodle_cli-0.1.0b0.dist-info/entry_points.txt +2 -0
- moodle_cli-0.1.0b0.dist-info/licenses/LICENSE +21 -0
moodle_cli/__init__.py
ADDED
moodle_cli/auth.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Authentication: extract MoodleSession cookie from env or browser."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from moodle_cli.constants import ENV_MOODLE_SESSION
|
|
8
|
+
from moodle_cli.exceptions import AuthError
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_from_env() -> str | None:
|
|
14
|
+
"""Check MOODLE_SESSION environment variable."""
|
|
15
|
+
value = os.environ.get(ENV_MOODLE_SESSION)
|
|
16
|
+
if value:
|
|
17
|
+
log.debug("Using MoodleSession from environment variable")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_from_browser(domain: str) -> str | None:
|
|
22
|
+
"""Extract MoodleSession cookie from browser cookie stores.
|
|
23
|
+
|
|
24
|
+
Tries Arc, Chrome, Brave, Edge, Firefox in order.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
import browser_cookie3 # noqa: F811
|
|
28
|
+
except ImportError:
|
|
29
|
+
log.warning("browser-cookie3 not installed; skipping browser cookie extraction")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
# browser-cookie3 loaders to try (in priority order)
|
|
33
|
+
loaders = [
|
|
34
|
+
("Chrome", browser_cookie3.chrome),
|
|
35
|
+
("Firefox", browser_cookie3.firefox),
|
|
36
|
+
("Brave", browser_cookie3.brave),
|
|
37
|
+
("Edge", browser_cookie3.edge),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for name, loader in loaders:
|
|
41
|
+
try:
|
|
42
|
+
cj = loader(domain_name=domain)
|
|
43
|
+
for cookie in cj:
|
|
44
|
+
if cookie.name == "MoodleSession" and domain in (cookie.domain or ""):
|
|
45
|
+
log.debug("Found MoodleSession in %s", name)
|
|
46
|
+
return cookie.value
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
log.debug("Could not read cookies from %s: %s", name, exc)
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_session(base_url: str) -> str:
|
|
54
|
+
"""Get a valid MoodleSession cookie value.
|
|
55
|
+
|
|
56
|
+
Priority: env var → browser cookies.
|
|
57
|
+
Raises AuthError if no session is found.
|
|
58
|
+
"""
|
|
59
|
+
# 1. Environment variable
|
|
60
|
+
session = load_from_env()
|
|
61
|
+
if session:
|
|
62
|
+
return session
|
|
63
|
+
|
|
64
|
+
# 2. Browser cookies
|
|
65
|
+
domain = urlparse(base_url).hostname or ""
|
|
66
|
+
session = load_from_browser(domain)
|
|
67
|
+
if session:
|
|
68
|
+
return session
|
|
69
|
+
|
|
70
|
+
raise AuthError(
|
|
71
|
+
"No MoodleSession found. Either:\n"
|
|
72
|
+
f" 1. Log in to {base_url} in your browser, or\n"
|
|
73
|
+
f" 2. Set the {ENV_MOODLE_SESSION} environment variable"
|
|
74
|
+
)
|
moodle_cli/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CLI entry point using Click."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from moodle_cli import __version__
|
|
10
|
+
from moodle_cli.auth import get_session
|
|
11
|
+
from moodle_cli.client import MoodleClient
|
|
12
|
+
from moodle_cli.config import load_config
|
|
13
|
+
from moodle_cli.exceptions import AuthError, MoodleAPIError, MoodleCLIError
|
|
14
|
+
from moodle_cli.formatter import print_courses, print_course_contents, print_user_info
|
|
15
|
+
from moodle_cli.output import output_json, output_yaml
|
|
16
|
+
|
|
17
|
+
console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
|
|
22
|
+
@click.version_option(version=__version__)
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def cli(ctx: click.Context, verbose: bool) -> None:
|
|
25
|
+
"""Terminal-first CLI for Moodle LMS."""
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.DEBUG if verbose else logging.WARNING,
|
|
28
|
+
format="%(name)s: %(message)s",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
config = load_config()
|
|
32
|
+
ctx.ensure_object(dict)
|
|
33
|
+
ctx.obj["config"] = config
|
|
34
|
+
|
|
35
|
+
# Lazy client creation — only authenticate when a command needs it
|
|
36
|
+
ctx.obj["_client"] = None
|
|
37
|
+
|
|
38
|
+
def get_client() -> MoodleClient:
|
|
39
|
+
if ctx.obj["_client"] is None:
|
|
40
|
+
session_cookie = get_session(config["base_url"])
|
|
41
|
+
ctx.obj["_client"] = MoodleClient(config["base_url"], session_cookie)
|
|
42
|
+
return ctx.obj["_client"]
|
|
43
|
+
|
|
44
|
+
ctx.obj["get_client"] = get_client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@cli.command(name="user")
|
|
48
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
49
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
50
|
+
@click.pass_context
|
|
51
|
+
def user(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
|
|
52
|
+
"""Show authenticated user info."""
|
|
53
|
+
client = ctx.obj["get_client"]()
|
|
54
|
+
info = client.get_site_info()
|
|
55
|
+
|
|
56
|
+
if as_json:
|
|
57
|
+
output_json(info.to_dict())
|
|
58
|
+
elif as_yaml:
|
|
59
|
+
output_yaml(info.to_dict())
|
|
60
|
+
else:
|
|
61
|
+
print_user_info(info)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@cli.command()
|
|
65
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
66
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def courses(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
|
|
69
|
+
"""List enrolled courses."""
|
|
70
|
+
client = ctx.obj["get_client"]()
|
|
71
|
+
course_list = client.get_courses()
|
|
72
|
+
|
|
73
|
+
if as_json:
|
|
74
|
+
output_json([c.to_dict() for c in course_list])
|
|
75
|
+
elif as_yaml:
|
|
76
|
+
output_yaml([c.to_dict() for c in course_list])
|
|
77
|
+
else:
|
|
78
|
+
print_courses(course_list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cli.command()
|
|
82
|
+
@click.argument("course_id", type=int)
|
|
83
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
84
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
85
|
+
@click.pass_context
|
|
86
|
+
def activities(ctx: click.Context, course_id: int, as_json: bool, as_yaml: bool) -> None:
|
|
87
|
+
"""List activities in a course (sections and modules)."""
|
|
88
|
+
client = ctx.obj["get_client"]()
|
|
89
|
+
sections = client.get_course_contents(course_id)
|
|
90
|
+
|
|
91
|
+
if as_json:
|
|
92
|
+
output_json([s.to_dict() for s in sections])
|
|
93
|
+
elif as_yaml:
|
|
94
|
+
output_yaml([s.to_dict() for s in sections])
|
|
95
|
+
else:
|
|
96
|
+
print_course_contents(sections, course_label=f"Course {course_id}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cli.command()
|
|
100
|
+
@click.argument("course_id", type=int)
|
|
101
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
102
|
+
@click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
|
|
103
|
+
@click.pass_context
|
|
104
|
+
def course(ctx: click.Context, course_id: int, as_json: bool, as_yaml: bool) -> None:
|
|
105
|
+
"""Show course detail with sections."""
|
|
106
|
+
client = ctx.obj["get_client"]()
|
|
107
|
+
sections = client.get_course_contents(course_id)
|
|
108
|
+
|
|
109
|
+
if as_json:
|
|
110
|
+
output_json([s.to_dict() for s in sections])
|
|
111
|
+
elif as_yaml:
|
|
112
|
+
output_yaml([s.to_dict() for s in sections])
|
|
113
|
+
else:
|
|
114
|
+
print_course_contents(sections, course_label=f"Course {course_id}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> None:
|
|
118
|
+
"""Entry point with error handling."""
|
|
119
|
+
try:
|
|
120
|
+
cli(standalone_mode=False)
|
|
121
|
+
except click.exceptions.Abort:
|
|
122
|
+
sys.exit(130)
|
|
123
|
+
except click.exceptions.Exit as e:
|
|
124
|
+
sys.exit(e.exit_code)
|
|
125
|
+
except click.ClickException as e:
|
|
126
|
+
e.show()
|
|
127
|
+
sys.exit(e.exit_code)
|
|
128
|
+
except AuthError as e:
|
|
129
|
+
console.print(f"[bold red]Auth error:[/] {e}")
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
except MoodleAPIError as e:
|
|
132
|
+
console.print(f"[bold red]API error:[/] {e}")
|
|
133
|
+
if e.error_code:
|
|
134
|
+
console.print(f" Error code: {e.error_code}")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
except MoodleCLIError as e:
|
|
137
|
+
console.print(f"[bold red]Error:[/] {e}")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
moodle_cli/client.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Moodle client using authenticated page scraping and AJAX fallbacks."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from moodle_cli.constants import (
|
|
8
|
+
AJAX_SERVICE_PATH,
|
|
9
|
+
COURSE_PATH,
|
|
10
|
+
DASHBOARD_PATH,
|
|
11
|
+
FUNC_GET_COURSES,
|
|
12
|
+
FUNC_GET_COURSES_BY_TIMELINE,
|
|
13
|
+
FUNC_GET_COURSE_CONTENTS,
|
|
14
|
+
FUNC_GET_SITE_INFO,
|
|
15
|
+
)
|
|
16
|
+
from moodle_cli.exceptions import AuthError, MoodleAPIError
|
|
17
|
+
from moodle_cli.models import Course, Section, UserInfo
|
|
18
|
+
from moodle_cli.parser import parse_courses, parse_user_info
|
|
19
|
+
from moodle_cli.scraper import parse_course_contents_html, parse_course_section_numbers, parse_page_context
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MoodleClient:
|
|
25
|
+
"""Client for Moodle's authenticated pages and internal AJAX API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, base_url: str, moodle_session: str):
|
|
28
|
+
self.base_url = base_url
|
|
29
|
+
self.session = requests.Session()
|
|
30
|
+
self.session.cookies.set("MoodleSession", moodle_session)
|
|
31
|
+
|
|
32
|
+
self._sesskey: str | None = None
|
|
33
|
+
self._userid: int | None = None
|
|
34
|
+
self._user_info: UserInfo | None = None
|
|
35
|
+
|
|
36
|
+
def _ajax_url(self, function_name: str) -> str:
|
|
37
|
+
"""Build the AJAX service URL."""
|
|
38
|
+
sesskey = self._sesskey or ""
|
|
39
|
+
return f"{self.base_url}{AJAX_SERVICE_PATH}?sesskey={sesskey}&info={function_name}"
|
|
40
|
+
|
|
41
|
+
def _get(self, path: str, params: dict | None = None) -> requests.Response:
|
|
42
|
+
"""Fetch an authenticated Moodle page."""
|
|
43
|
+
resp = self.session.get(f"{self.base_url}{path}", params=params)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
return resp
|
|
46
|
+
|
|
47
|
+
def _call(self, function_name: str, args: dict | None = None) -> dict | list:
|
|
48
|
+
"""Make a single AJAX service call.
|
|
49
|
+
|
|
50
|
+
Returns the 'data' field from the first response item.
|
|
51
|
+
"""
|
|
52
|
+
payload = [
|
|
53
|
+
{
|
|
54
|
+
"index": 0,
|
|
55
|
+
"methodname": function_name,
|
|
56
|
+
"args": args or {},
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
url = self._ajax_url(function_name)
|
|
61
|
+
log.debug("POST %s", url)
|
|
62
|
+
|
|
63
|
+
resp = self.session.post(url, json=payload)
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
|
|
66
|
+
result = resp.json()
|
|
67
|
+
|
|
68
|
+
# Moodle returns a JSON array
|
|
69
|
+
if isinstance(result, list) and len(result) > 0:
|
|
70
|
+
item = result[0]
|
|
71
|
+
if item.get("error"):
|
|
72
|
+
exc = item.get("exception", {})
|
|
73
|
+
raise MoodleAPIError(
|
|
74
|
+
exc.get("message", "Unknown API error"),
|
|
75
|
+
error_code=exc.get("errorcode"),
|
|
76
|
+
)
|
|
77
|
+
return item.get("data", item)
|
|
78
|
+
|
|
79
|
+
# Some endpoints return a dict with error info
|
|
80
|
+
if isinstance(result, dict) and result.get("error"):
|
|
81
|
+
raise MoodleAPIError(
|
|
82
|
+
result.get("message", "Unknown error"),
|
|
83
|
+
error_code=result.get("errorcode"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
def _ensure_session(self) -> None:
|
|
89
|
+
"""Ensure we have an authenticated Moodle context."""
|
|
90
|
+
if self._sesskey and self._userid:
|
|
91
|
+
return
|
|
92
|
+
response = self._get(DASHBOARD_PATH)
|
|
93
|
+
context = parse_page_context(response.text, self.base_url)
|
|
94
|
+
self._sesskey = context.sesskey
|
|
95
|
+
self._userid = context.user_info.userid
|
|
96
|
+
self._user_info = context.user_info
|
|
97
|
+
|
|
98
|
+
def get_site_info(self) -> UserInfo:
|
|
99
|
+
"""Load authenticated user info, falling back to page scraping when needed."""
|
|
100
|
+
self._ensure_session()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
data = self._call(FUNC_GET_SITE_INFO)
|
|
104
|
+
except MoodleAPIError as exc:
|
|
105
|
+
if exc.error_code != "servicenotavailable":
|
|
106
|
+
raise
|
|
107
|
+
log.debug("Falling back to scraped user info because %s is unavailable", FUNC_GET_SITE_INFO)
|
|
108
|
+
else:
|
|
109
|
+
if not isinstance(data, dict) or "userid" not in data:
|
|
110
|
+
raise AuthError("Session appears invalid — could not retrieve user info")
|
|
111
|
+
|
|
112
|
+
self._sesskey = data.get("sesskey") or self._sesskey
|
|
113
|
+
self._userid = data["userid"]
|
|
114
|
+
self._user_info = parse_user_info(data)
|
|
115
|
+
|
|
116
|
+
if self._user_info is None or self._userid is None:
|
|
117
|
+
raise AuthError("Session appears invalid — could not retrieve user info")
|
|
118
|
+
|
|
119
|
+
log.debug("Authenticated as %s (uid=%d)", self._user_info.fullname, self._userid)
|
|
120
|
+
return self._user_info
|
|
121
|
+
|
|
122
|
+
def get_courses(self) -> list[Course]:
|
|
123
|
+
"""Get all enrolled courses for the authenticated user."""
|
|
124
|
+
self._ensure_session()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
return self._get_courses_timeline()
|
|
128
|
+
except MoodleAPIError as exc:
|
|
129
|
+
if exc.error_code != "servicenotavailable":
|
|
130
|
+
raise
|
|
131
|
+
log.debug("Falling back to %s because timeline API is unavailable", FUNC_GET_COURSES)
|
|
132
|
+
|
|
133
|
+
data = self._call(FUNC_GET_COURSES, {"userid": self._userid})
|
|
134
|
+
if not isinstance(data, list):
|
|
135
|
+
return []
|
|
136
|
+
return parse_courses(data)
|
|
137
|
+
|
|
138
|
+
def get_course_contents(self, course_id: int) -> list[Section]:
|
|
139
|
+
"""Get sections and activities for a course."""
|
|
140
|
+
self._ensure_session()
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
data = self._call(FUNC_GET_COURSE_CONTENTS, {"courseid": course_id})
|
|
144
|
+
except MoodleAPIError as exc:
|
|
145
|
+
if exc.error_code != "servicenotavailable":
|
|
146
|
+
raise
|
|
147
|
+
log.debug("Falling back to scraping %s because %s is unavailable", COURSE_PATH, FUNC_GET_COURSE_CONTENTS)
|
|
148
|
+
else:
|
|
149
|
+
if isinstance(data, list):
|
|
150
|
+
from moodle_cli.parser import parse_course_contents
|
|
151
|
+
|
|
152
|
+
return parse_course_contents(data)
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
response = self._get(COURSE_PATH, {"id": course_id})
|
|
156
|
+
sections = self._scrape_course_contents(course_id, response.text)
|
|
157
|
+
return sections
|
|
158
|
+
|
|
159
|
+
def _get_courses_timeline(self) -> list[Course]:
|
|
160
|
+
"""Get enrolled courses from the dashboard timeline API."""
|
|
161
|
+
courses: list[dict] = []
|
|
162
|
+
offset = 0
|
|
163
|
+
|
|
164
|
+
while True:
|
|
165
|
+
data = self._call(
|
|
166
|
+
FUNC_GET_COURSES_BY_TIMELINE,
|
|
167
|
+
{"classification": "all", "limit": 100, "offset": offset},
|
|
168
|
+
)
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
batch = data.get("courses", [])
|
|
173
|
+
if not isinstance(batch, list) or not batch:
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
courses.extend(batch)
|
|
177
|
+
|
|
178
|
+
next_offset = data.get("nextoffset", offset)
|
|
179
|
+
if not isinstance(next_offset, int) or next_offset <= offset:
|
|
180
|
+
break
|
|
181
|
+
offset = next_offset
|
|
182
|
+
|
|
183
|
+
return parse_courses(courses)
|
|
184
|
+
|
|
185
|
+
def _scrape_course_contents(self, course_id: int, root_html: str) -> list[Section]:
|
|
186
|
+
"""Aggregate sections from section-specific course pages."""
|
|
187
|
+
section_numbers = parse_course_section_numbers(root_html, course_id)
|
|
188
|
+
html_pages = [root_html]
|
|
189
|
+
|
|
190
|
+
for section_num in section_numbers:
|
|
191
|
+
if section_num == 0:
|
|
192
|
+
continue
|
|
193
|
+
response = self._get(COURSE_PATH, {"id": course_id, "section": section_num})
|
|
194
|
+
html_pages.append(response.text)
|
|
195
|
+
|
|
196
|
+
sections: list[Section] = []
|
|
197
|
+
seen_sections: set[int] = set()
|
|
198
|
+
for html in html_pages:
|
|
199
|
+
for section in parse_course_contents_html(html, self.base_url):
|
|
200
|
+
section_key = section.section or section.id
|
|
201
|
+
if section_key in seen_sections:
|
|
202
|
+
continue
|
|
203
|
+
seen_sections.add(section_key)
|
|
204
|
+
sections.append(section)
|
|
205
|
+
|
|
206
|
+
return sections
|
moodle_cli/config.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Config loading and first-run base URL setup."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from moodle_cli.constants import CONFIG_DIR, CONFIG_FILENAME, ENV_MOODLE_BASE_URL
|
|
12
|
+
from moodle_cli.exceptions import MoodleCLIError
|
|
13
|
+
|
|
14
|
+
CONFIG_PROMPT = "Enter your Moodle base URL"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _config_candidates() -> list[Path]:
|
|
18
|
+
return [
|
|
19
|
+
Path.cwd() / CONFIG_FILENAME,
|
|
20
|
+
Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME,
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_config_file() -> Path | None:
|
|
25
|
+
"""Search for config.yaml in CWD, then ~/.config/moodle-cli/."""
|
|
26
|
+
for path in _config_candidates():
|
|
27
|
+
if path.is_file():
|
|
28
|
+
return path
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_config_path() -> Path:
|
|
33
|
+
cwd_config = Path.cwd() / CONFIG_FILENAME
|
|
34
|
+
if cwd_config.exists():
|
|
35
|
+
return cwd_config
|
|
36
|
+
return Path(CONFIG_DIR).expanduser() / CONFIG_FILENAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_config_file(path: Path) -> dict:
|
|
40
|
+
with open(path, encoding="utf-8") as f:
|
|
41
|
+
return yaml.safe_load(f) or {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save_config(path: Path, config: dict) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
47
|
+
yaml.safe_dump(config, f, sort_keys=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_base_url(value: str) -> str:
|
|
51
|
+
raw = value.strip()
|
|
52
|
+
if not raw:
|
|
53
|
+
raise MoodleCLIError("Base URL cannot be empty.")
|
|
54
|
+
if "://" not in raw:
|
|
55
|
+
raise MoodleCLIError("Base URL must include the scheme, for example https://school.example.edu")
|
|
56
|
+
|
|
57
|
+
parsed = urlparse(raw)
|
|
58
|
+
if parsed.scheme not in {"http", "https"}:
|
|
59
|
+
raise MoodleCLIError("Base URL must start with http:// or https://")
|
|
60
|
+
if not parsed.hostname:
|
|
61
|
+
raise MoodleCLIError("Base URL must include a hostname")
|
|
62
|
+
if parsed.query or parsed.fragment:
|
|
63
|
+
raise MoodleCLIError("Base URL must not include query parameters or fragments")
|
|
64
|
+
if parsed.path not in {"", "/"}:
|
|
65
|
+
raise MoodleCLIError("Base URL must be the site root, for example https://school.example.edu")
|
|
66
|
+
|
|
67
|
+
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _probe_base_url(base_url: str) -> tuple[bool, str]:
|
|
71
|
+
"""Check whether the configured URL looks reachable and likely Moodle."""
|
|
72
|
+
try:
|
|
73
|
+
response = requests.get(f"{base_url}/login/index.php", timeout=10, allow_redirects=True)
|
|
74
|
+
except requests.RequestException as exc:
|
|
75
|
+
return False, f"Could not reach {base_url}: {exc}"
|
|
76
|
+
|
|
77
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
78
|
+
body = response.text[:5000].lower()
|
|
79
|
+
looks_html = "text/html" in content_type or "<html" in body
|
|
80
|
+
looks_moodle = "moodle" in body or "log in" in body or "/login/index.php" in body
|
|
81
|
+
|
|
82
|
+
if response.status_code >= 400:
|
|
83
|
+
return False, f"{base_url} returned HTTP {response.status_code}"
|
|
84
|
+
if not looks_html:
|
|
85
|
+
return False, f"{base_url} did not return an HTML page"
|
|
86
|
+
if not looks_moodle:
|
|
87
|
+
return False, f"{base_url} does not look like a Moodle site"
|
|
88
|
+
|
|
89
|
+
return True, ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _prompt_for_base_url() -> str:
|
|
93
|
+
click.echo("No Moodle base URL configured.")
|
|
94
|
+
click.echo("Example: https://school.example.edu")
|
|
95
|
+
|
|
96
|
+
while True:
|
|
97
|
+
value = click.prompt(CONFIG_PROMPT, type=str)
|
|
98
|
+
try:
|
|
99
|
+
base_url = _validate_base_url(value)
|
|
100
|
+
except MoodleCLIError as exc:
|
|
101
|
+
click.echo(f"Invalid URL: {exc}")
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
looks_valid, message = _probe_base_url(base_url)
|
|
105
|
+
if not looks_valid:
|
|
106
|
+
click.echo(f"Warning: {message}")
|
|
107
|
+
if not click.confirm("Save this URL anyway?", default=False):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if click.confirm(f"Use {base_url}?", default=True):
|
|
111
|
+
return base_url
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config() -> dict:
|
|
115
|
+
"""Load configuration, prompting once for base_url when needed."""
|
|
116
|
+
config: dict = {}
|
|
117
|
+
config_file = _find_config_file()
|
|
118
|
+
if config_file:
|
|
119
|
+
config = _read_config_file(config_file)
|
|
120
|
+
|
|
121
|
+
if env_url := os.environ.get(ENV_MOODLE_BASE_URL):
|
|
122
|
+
config["base_url"] = _validate_base_url(env_url)
|
|
123
|
+
return config
|
|
124
|
+
|
|
125
|
+
if base_url := config.get("base_url"):
|
|
126
|
+
config["base_url"] = _validate_base_url(str(base_url))
|
|
127
|
+
return config
|
|
128
|
+
|
|
129
|
+
if not click.get_text_stream("stdin").isatty():
|
|
130
|
+
raise MoodleCLIError(
|
|
131
|
+
"No base_url configured. Set MOODLE_BASE_URL or add base_url to config.yaml before running non-interactively."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
base_url = _prompt_for_base_url()
|
|
135
|
+
config["base_url"] = base_url
|
|
136
|
+
|
|
137
|
+
target_path = config_file or _default_config_path()
|
|
138
|
+
_save_config(target_path, config)
|
|
139
|
+
click.echo(f"Saved base_url to {target_path}")
|
|
140
|
+
return config
|
moodle_cli/constants.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Default values and API paths."""
|
|
2
|
+
|
|
3
|
+
AJAX_SERVICE_PATH = "/lib/ajax/service.php"
|
|
4
|
+
DASHBOARD_PATH = "/my/"
|
|
5
|
+
COURSE_PATH = "/course/view.php"
|
|
6
|
+
|
|
7
|
+
# Moodle AJAX function names
|
|
8
|
+
FUNC_GET_SITE_INFO = "core_webservice_get_site_info"
|
|
9
|
+
FUNC_GET_COURSES = "core_enrol_get_users_courses"
|
|
10
|
+
FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification"
|
|
11
|
+
FUNC_GET_COURSE_CONTENTS = "core_course_get_contents"
|
|
12
|
+
|
|
13
|
+
# Config file locations (checked in order)
|
|
14
|
+
CONFIG_FILENAME = "config.yaml"
|
|
15
|
+
CONFIG_DIR = "~/.config/moodle-cli"
|
|
16
|
+
|
|
17
|
+
# Environment variable names
|
|
18
|
+
ENV_MOODLE_SESSION = "MOODLE_SESSION"
|
|
19
|
+
ENV_MOODLE_BASE_URL = "MOODLE_BASE_URL"
|
moodle_cli/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exceptions for moodle-cli."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MoodleCLIError(Exception):
|
|
5
|
+
"""Base exception for moodle-cli."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthError(MoodleCLIError):
|
|
9
|
+
"""Authentication failed — no valid session found."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MoodleAPIError(MoodleCLIError):
|
|
13
|
+
"""Moodle API returned an error response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, error_code: str | None = None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.error_code = error_code
|
moodle_cli/formatter.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Rich terminal output: tables and trees for Moodle data."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.tree import Tree
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from moodle_cli.models import Course, Section, UserInfo
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def print_user_info(user: UserInfo) -> None:
|
|
14
|
+
"""Display authenticated user info."""
|
|
15
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
16
|
+
table.add_column(style="bold cyan")
|
|
17
|
+
table.add_column()
|
|
18
|
+
|
|
19
|
+
table.add_row("User", user.fullname)
|
|
20
|
+
if user.username:
|
|
21
|
+
table.add_row("Username", user.username)
|
|
22
|
+
table.add_row("User ID", str(user.userid))
|
|
23
|
+
table.add_row("Site", user.sitename)
|
|
24
|
+
table.add_row("URL", user.siteurl)
|
|
25
|
+
if user.lang:
|
|
26
|
+
table.add_row("Language", user.lang)
|
|
27
|
+
|
|
28
|
+
console.print(table)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_courses(courses: list[Course]) -> None:
|
|
32
|
+
"""Display enrolled courses as a table."""
|
|
33
|
+
table = Table(title="Enrolled Courses")
|
|
34
|
+
table.add_column("ID", style="dim", justify="right")
|
|
35
|
+
table.add_column("Short Name", style="bold")
|
|
36
|
+
table.add_column("Full Name")
|
|
37
|
+
table.add_column("Visible", justify="center")
|
|
38
|
+
|
|
39
|
+
for c in courses:
|
|
40
|
+
visible = "[green]Yes[/]" if c.visible else "[red]No[/]"
|
|
41
|
+
table.add_row(str(c.id), c.shortname, c.fullname, visible)
|
|
42
|
+
|
|
43
|
+
console.print(table)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def print_course_contents(sections: list[Section], course_label: str = "Course") -> None:
|
|
47
|
+
"""Display course sections and activities as a tree."""
|
|
48
|
+
tree = Tree(f"[bold]{course_label}[/bold]")
|
|
49
|
+
|
|
50
|
+
for section in sections:
|
|
51
|
+
label = section.name or f"Section {section.section}"
|
|
52
|
+
if not section.visible:
|
|
53
|
+
label += " [dim](hidden)[/dim]"
|
|
54
|
+
branch = tree.add(f"[bold yellow]{label}[/bold yellow]")
|
|
55
|
+
|
|
56
|
+
if not section.activities:
|
|
57
|
+
branch.add("[dim]No activities[/dim]")
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
for activity in section.activities:
|
|
61
|
+
icon = _activity_icon(activity.modname)
|
|
62
|
+
name = activity.name
|
|
63
|
+
if not activity.visible:
|
|
64
|
+
name += " [dim](hidden)[/dim]"
|
|
65
|
+
branch.add(f"{icon} {name} [dim]({activity.modname})[/dim]")
|
|
66
|
+
|
|
67
|
+
console.print(tree)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _activity_icon(modname: str) -> str:
|
|
71
|
+
"""Map Moodle module types to terminal-friendly icons."""
|
|
72
|
+
icons = {
|
|
73
|
+
"assign": "[red]A[/]",
|
|
74
|
+
"quiz": "[magenta]Q[/]",
|
|
75
|
+
"forum": "[green]F[/]",
|
|
76
|
+
"resource": "[blue]R[/]",
|
|
77
|
+
"url": "[cyan]U[/]",
|
|
78
|
+
"page": "[white]P[/]",
|
|
79
|
+
"folder": "[yellow]D[/]",
|
|
80
|
+
"label": "[dim]L[/]",
|
|
81
|
+
"choice": "[magenta]C[/]",
|
|
82
|
+
"feedback": "[green]B[/]",
|
|
83
|
+
"workshop": "[red]W[/]",
|
|
84
|
+
"glossary": "[cyan]G[/]",
|
|
85
|
+
"wiki": "[white]K[/]",
|
|
86
|
+
"book": "[blue]B[/]",
|
|
87
|
+
"h5pactivity": "[magenta]H[/]",
|
|
88
|
+
"lti": "[yellow]E[/]",
|
|
89
|
+
}
|
|
90
|
+
return icons.get(modname, "[dim]·[/]")
|
moodle_cli/models.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Data models for Moodle entities."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class UserInfo:
|
|
8
|
+
userid: int
|
|
9
|
+
username: str
|
|
10
|
+
fullname: str
|
|
11
|
+
sitename: str
|
|
12
|
+
siteurl: str
|
|
13
|
+
lang: str = ""
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> dict:
|
|
16
|
+
return {
|
|
17
|
+
"userid": self.userid,
|
|
18
|
+
"username": self.username,
|
|
19
|
+
"fullname": self.fullname,
|
|
20
|
+
"sitename": self.sitename,
|
|
21
|
+
"siteurl": self.siteurl,
|
|
22
|
+
"lang": self.lang,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Course:
|
|
28
|
+
id: int
|
|
29
|
+
shortname: str
|
|
30
|
+
fullname: str
|
|
31
|
+
category: int = 0
|
|
32
|
+
visible: bool = True
|
|
33
|
+
startdate: int = 0
|
|
34
|
+
enddate: int = 0
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"id": self.id,
|
|
39
|
+
"shortname": self.shortname,
|
|
40
|
+
"fullname": self.fullname,
|
|
41
|
+
"category": self.category,
|
|
42
|
+
"visible": self.visible,
|
|
43
|
+
"startdate": self.startdate,
|
|
44
|
+
"enddate": self.enddate,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Activity:
|
|
50
|
+
id: int
|
|
51
|
+
name: str
|
|
52
|
+
modname: str # e.g. "assign", "forum", "resource", "url"
|
|
53
|
+
url: str = ""
|
|
54
|
+
visible: bool = True
|
|
55
|
+
description: str = ""
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"id": self.id,
|
|
60
|
+
"name": self.name,
|
|
61
|
+
"modname": self.modname,
|
|
62
|
+
"url": self.url,
|
|
63
|
+
"visible": self.visible,
|
|
64
|
+
"description": self.description,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Section:
|
|
70
|
+
id: int
|
|
71
|
+
name: str
|
|
72
|
+
section: int # position/order number
|
|
73
|
+
visible: bool = True
|
|
74
|
+
summary: str = ""
|
|
75
|
+
activities: list[Activity] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
return {
|
|
79
|
+
"id": self.id,
|
|
80
|
+
"name": self.name,
|
|
81
|
+
"section": self.section,
|
|
82
|
+
"visible": self.visible,
|
|
83
|
+
"summary": self.summary,
|
|
84
|
+
"activities": [a.to_dict() for a in self.activities],
|
|
85
|
+
}
|
moodle_cli/output.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Structured output: --json and --yaml support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def output_json(data: dict | list) -> None:
|
|
10
|
+
"""Print data as formatted JSON to stdout."""
|
|
11
|
+
json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
|
|
12
|
+
sys.stdout.write("\n")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def output_yaml(data: dict | list) -> None:
|
|
16
|
+
"""Print data as YAML to stdout."""
|
|
17
|
+
yaml.dump(data, sys.stdout, default_flow_style=False, allow_unicode=True)
|
moodle_cli/parser.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Parse raw Moodle JSON responses into typed models."""
|
|
2
|
+
|
|
3
|
+
from moodle_cli.models import Activity, Course, Section, UserInfo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_user_info(data: dict) -> UserInfo:
|
|
7
|
+
return UserInfo(
|
|
8
|
+
userid=data["userid"],
|
|
9
|
+
username=data["username"],
|
|
10
|
+
fullname=data["fullname"],
|
|
11
|
+
sitename=data["sitename"],
|
|
12
|
+
siteurl=data["siteurl"],
|
|
13
|
+
lang=data.get("lang", ""),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_course(data: dict) -> Course:
|
|
18
|
+
return Course(
|
|
19
|
+
id=data["id"],
|
|
20
|
+
shortname=data.get("shortname", ""),
|
|
21
|
+
fullname=data.get("fullname", ""),
|
|
22
|
+
category=data.get("category", 0),
|
|
23
|
+
visible=bool(data.get("visible", True)),
|
|
24
|
+
startdate=data.get("startdate", 0),
|
|
25
|
+
enddate=data.get("enddate", 0),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_courses(data: list[dict]) -> list[Course]:
|
|
30
|
+
return [parse_course(c) for c in data]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_activity(data: dict) -> Activity:
|
|
34
|
+
return Activity(
|
|
35
|
+
id=data["id"],
|
|
36
|
+
name=data.get("name", ""),
|
|
37
|
+
modname=data.get("modname", ""),
|
|
38
|
+
url=data.get("url", ""),
|
|
39
|
+
visible=bool(data.get("visible", True)),
|
|
40
|
+
description=data.get("description", ""),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_section(data: dict) -> Section:
|
|
45
|
+
modules = data.get("modules", [])
|
|
46
|
+
return Section(
|
|
47
|
+
id=data["id"],
|
|
48
|
+
name=data.get("name", ""),
|
|
49
|
+
section=data.get("section", 0),
|
|
50
|
+
visible=bool(data.get("visible", True)),
|
|
51
|
+
summary=data.get("summary", ""),
|
|
52
|
+
activities=[parse_activity(m) for m in modules],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_course_contents(data: list[dict]) -> list[Section]:
|
|
57
|
+
return [parse_section(s) for s in data]
|
moodle_cli/scraper.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HTML scraping helpers for authenticated Moodle pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from html import unescape
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
12
|
+
|
|
13
|
+
from moodle_cli.exceptions import AuthError
|
|
14
|
+
from moodle_cli.models import Activity, Section, UserInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PageContext:
|
|
19
|
+
"""Minimal authenticated context extracted from a Moodle page."""
|
|
20
|
+
|
|
21
|
+
sesskey: str
|
|
22
|
+
user_info: UserInfo
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_page_context(html: str, base_url: str) -> PageContext:
|
|
26
|
+
"""Extract sesskey and user identity from an authenticated page."""
|
|
27
|
+
config = _parse_moodle_config(html)
|
|
28
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
29
|
+
|
|
30
|
+
sesskey = str(config.get("sesskey") or "").strip()
|
|
31
|
+
user_id = int(config.get("userId") or _search_int(html, r'data-user-id="(\d+)"'))
|
|
32
|
+
fullname = _clean_text_from_node(soup.select_one(".userfullname"))
|
|
33
|
+
sitename = _extract_sitename(soup)
|
|
34
|
+
fallback_lang = soup.html.get("lang", "") if soup.html else ""
|
|
35
|
+
lang = str(config.get("language") or fallback_lang)
|
|
36
|
+
|
|
37
|
+
if not sesskey or not user_id or not fullname:
|
|
38
|
+
raise AuthError("Session appears invalid — could not load authenticated Moodle context")
|
|
39
|
+
|
|
40
|
+
return PageContext(
|
|
41
|
+
sesskey=sesskey,
|
|
42
|
+
user_info=UserInfo(
|
|
43
|
+
userid=user_id,
|
|
44
|
+
username="",
|
|
45
|
+
fullname=fullname,
|
|
46
|
+
sitename=sitename,
|
|
47
|
+
siteurl=base_url,
|
|
48
|
+
lang=lang,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_course_contents_html(html: str, base_url: str) -> list[Section]:
|
|
54
|
+
"""Parse rendered Moodle course HTML into sections and activities."""
|
|
55
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
56
|
+
sections: list[Section] = []
|
|
57
|
+
|
|
58
|
+
for section_el in soup.select('li[data-for="section"]'):
|
|
59
|
+
section_id = _safe_int(section_el.get("data-id"))
|
|
60
|
+
section_num = _safe_int(section_el.get("data-number") or section_el.get("data-sectionnum"))
|
|
61
|
+
position_name = _clean_text_from_node(section_el.select_one(".course-section-position-name"))
|
|
62
|
+
main_name = _clean_text_from_node(
|
|
63
|
+
section_el.select_one("h1.sectionname, h2.sectionname, h3.sectionname")
|
|
64
|
+
or section_el.select_one('[data-for="section_title"] a')
|
|
65
|
+
or section_el.select_one('[data-for="section_title"]')
|
|
66
|
+
)
|
|
67
|
+
if position_name and main_name and position_name != main_name:
|
|
68
|
+
section_name = f"{position_name} - {main_name}"
|
|
69
|
+
else:
|
|
70
|
+
section_name = main_name or position_name or f"Section {section_num}"
|
|
71
|
+
|
|
72
|
+
summary_el = section_el.select_one(".summarytext, [data-for='sectioninfo']")
|
|
73
|
+
summary = _clean_text_from_node(summary_el)
|
|
74
|
+
section_visible = "hidden" not in section_el.get("class", [])
|
|
75
|
+
|
|
76
|
+
activities: list[Activity] = []
|
|
77
|
+
seen_activity_ids: set[int] = set()
|
|
78
|
+
for activity_el in section_el.select('li[data-for="cmitem"]'):
|
|
79
|
+
activity_id = _safe_int(activity_el.get("data-id"))
|
|
80
|
+
if activity_id and activity_id in seen_activity_ids:
|
|
81
|
+
continue
|
|
82
|
+
if activity_id:
|
|
83
|
+
seen_activity_ids.add(activity_id)
|
|
84
|
+
|
|
85
|
+
classes = activity_el.get("class", [])
|
|
86
|
+
modname = next((cls[8:] for cls in classes if cls.startswith("modtype_")), "")
|
|
87
|
+
card = activity_el.select_one('[data-region="activity-card"]') or activity_el.select_one(".activity-item")
|
|
88
|
+
|
|
89
|
+
activity_name = _clean_text_from_node(
|
|
90
|
+
activity_el.select_one(".activityname .instancename")
|
|
91
|
+
or activity_el.select_one(".activityname")
|
|
92
|
+
or activity_el.select_one("a.aalink")
|
|
93
|
+
)
|
|
94
|
+
if not activity_name and card is not None:
|
|
95
|
+
activity_name = (card.get("data-activityname") or "").strip()
|
|
96
|
+
if not activity_name:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
link = activity_el.select_one(".activityname a, a.aalink, a[href]")
|
|
100
|
+
href = link.get("href") if link else ""
|
|
101
|
+
description = _clean_text_from_node(
|
|
102
|
+
activity_el.select_one("[data-region='activity-description'], .contentafterlink, .description")
|
|
103
|
+
)
|
|
104
|
+
activity_visible = not any(flag in classes for flag in ("hidden", "stealth", "dimmed"))
|
|
105
|
+
|
|
106
|
+
activities.append(
|
|
107
|
+
Activity(
|
|
108
|
+
id=activity_id,
|
|
109
|
+
name=activity_name,
|
|
110
|
+
modname=modname,
|
|
111
|
+
url=urljoin(base_url, href) if href else "",
|
|
112
|
+
visible=activity_visible,
|
|
113
|
+
description=description,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
sections.append(
|
|
118
|
+
Section(
|
|
119
|
+
id=section_id,
|
|
120
|
+
name=section_name,
|
|
121
|
+
section=section_num,
|
|
122
|
+
visible=section_visible,
|
|
123
|
+
summary=summary,
|
|
124
|
+
activities=activities,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return sections
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_course_section_numbers(html: str, course_id: int) -> list[int]:
|
|
132
|
+
"""Extract the ordered section numbers exposed by the course navigation."""
|
|
133
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
134
|
+
section_numbers: list[int] = []
|
|
135
|
+
|
|
136
|
+
for link in soup.select(f'a[href*="/course/view.php?id={course_id}§ion="]'):
|
|
137
|
+
href = link.get("href") or ""
|
|
138
|
+
match = re.search(r"[?&]section=(\d+)", href)
|
|
139
|
+
if not match:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
section_num = int(match.group(1))
|
|
143
|
+
if section_num not in section_numbers:
|
|
144
|
+
section_numbers.append(section_num)
|
|
145
|
+
|
|
146
|
+
return section_numbers
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_moodle_config(html: str) -> dict:
|
|
150
|
+
match = re.search(r"M\.cfg\s*=\s*({.*?});", html, re.S)
|
|
151
|
+
if not match:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
return json.loads(match.group(1))
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extract_sitename(soup: BeautifulSoup) -> str:
|
|
161
|
+
title = _clean_text_from_node(soup.title)
|
|
162
|
+
if "|" in title:
|
|
163
|
+
return title.rsplit("|", 1)[1].strip()
|
|
164
|
+
return title
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _search_int(html: str, pattern: str) -> int:
|
|
168
|
+
match = re.search(pattern, html)
|
|
169
|
+
return int(match.group(1)) if match else 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _safe_int(value: str | None) -> int:
|
|
173
|
+
try:
|
|
174
|
+
return int(value or 0)
|
|
175
|
+
except ValueError:
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _clean_text_from_node(node) -> str:
|
|
180
|
+
if node is None:
|
|
181
|
+
return ""
|
|
182
|
+
return _clean_text(node.get_text(" ", strip=True))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _clean_text(value: str) -> str:
|
|
186
|
+
return " ".join(unescape(value or "").split())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moodle-cli
|
|
3
|
+
Version: 0.1.0b0
|
|
4
|
+
Summary: Terminal-first CLI for Moodle LMS
|
|
5
|
+
Project-URL: Homepage, https://github.com/bunizao/moodle-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/bunizao/moodle-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/bunizao/moodle-cli/issues
|
|
8
|
+
Author: bunizao
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 bunizao
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
33
|
+
Requires-Dist: browser-cookie3>=0.19
|
|
34
|
+
Requires-Dist: click>=8.0
|
|
35
|
+
Requires-Dist: pyyaml>=6.0
|
|
36
|
+
Requires-Dist: requests>=2.28
|
|
37
|
+
Requires-Dist: rich>=13.0
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# moodle-cli
|
|
41
|
+
|
|
42
|
+
Terminal-first CLI for Moodle LMS that reuses an authenticated browser session.
|
|
43
|
+
|
|
44
|
+
Repository: <https://github.com/bunizao/moodle-cli>
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- No API token setup required
|
|
49
|
+
- Reads `MoodleSession` from your browser or `MOODLE_SESSION`
|
|
50
|
+
- Works with Moodle AJAX APIs and falls back to authenticated page scraping when needed
|
|
51
|
+
- Terminal output plus `--json` and `--yaml`
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python 3.10+
|
|
56
|
+
- `uv`
|
|
57
|
+
- An active Moodle browser session, or a `MOODLE_SESSION` environment variable
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv sync
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv run moodle --help
|
|
69
|
+
uv run moodle user
|
|
70
|
+
uv run moodle courses
|
|
71
|
+
uv run moodle activities 34637
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
On first run, the CLI will ask for your Moodle base URL and save it to `config.yaml`.
|
|
77
|
+
|
|
78
|
+
Safety checks during setup:
|
|
79
|
+
|
|
80
|
+
- Requires a full root URL such as `https://school.example.edu`
|
|
81
|
+
- Rejects paths, query strings, and fragments
|
|
82
|
+
- Probes the site before saving
|
|
83
|
+
- Requires explicit confirmation if the target does not look like Moodle
|
|
84
|
+
|
|
85
|
+
If you prefer to configure it manually, create `config.yaml` in the project directory or in `~/.config/moodle-cli/`:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
base_url: https://school.example.edu
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
You can copy from `config.example.yaml`.
|
|
92
|
+
|
|
93
|
+
Environment overrides:
|
|
94
|
+
|
|
95
|
+
- `MOODLE_BASE_URL`
|
|
96
|
+
- `MOODLE_SESSION`
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run python -m compileall moodle_cli
|
|
102
|
+
uv build
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## CI
|
|
106
|
+
|
|
107
|
+
GitHub Actions runs the following checks on pushes and pull requests:
|
|
108
|
+
|
|
109
|
+
- Dependency lock sync with `uv`
|
|
110
|
+
- Bytecode compilation
|
|
111
|
+
- CLI smoke check
|
|
112
|
+
- Package build
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
moodle_cli/__init__.py,sha256=m04x4InJgeyxvzMnKndLw-ef5zKtcF45SIfjo0eCyAQ,66
|
|
2
|
+
moodle_cli/auth.py,sha256=DBiMu_9niev-AiunRPwn3OJu9Xqg9YJ30VV-VOLyHV4,2184
|
|
3
|
+
moodle_cli/cli.py,sha256=okjfQqox8oSOyrIjNwCORk2V3OMDR-p6HuwBZRaEoDU,4581
|
|
4
|
+
moodle_cli/client.py,sha256=q-gmgoABAw0qvMg3ejnW9l4ZLGG6y5usIB_3AiPwkGM,7457
|
|
5
|
+
moodle_cli/config.py,sha256=dBHhumvsO7XQlFYh5wTtHD9zEOZJ2TA6lnHpP1EkeyA,4649
|
|
6
|
+
moodle_cli/constants.py,sha256=M_Oul6Wg19mbHn2x-RQtHIYMu2mNSdMDK6BoA4oFxdg,638
|
|
7
|
+
moodle_cli/exceptions.py,sha256=n4zyh1T2ET3z8CSYSkbMoP9t4fseRz0cOKW02rXeBuc,441
|
|
8
|
+
moodle_cli/formatter.py,sha256=jLtkCDNE-cq6LseWjwPhM8D2A_vUYs8mKUBhZ15PNUg,2860
|
|
9
|
+
moodle_cli/models.py,sha256=N2ovDmxIBUnOSHLlaYJtpAu6AqohcUR8PVo5rePdwg8,1931
|
|
10
|
+
moodle_cli/output.py,sha256=Eauf9jq6_ibQUr2omqtVpqSnRu77su-GdeL34fz-ahA,438
|
|
11
|
+
moodle_cli/parser.py,sha256=EUcoYhOg03_70uHoX_ZE7QKvSs7eC3rROVvycAbyKMM,1617
|
|
12
|
+
moodle_cli/scraper.py,sha256=LMtis0JBOO6KgYpNW7aqszmKSrX50F91wa0b5Q8YGeQ,6469
|
|
13
|
+
moodle_cli-0.1.0b0.dist-info/METADATA,sha256=Gww1emJzsS9WWWycOzMrGuF8bSa7iU7Ibe-Y-uNxzeE,3331
|
|
14
|
+
moodle_cli-0.1.0b0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
moodle_cli-0.1.0b0.dist-info/entry_points.txt,sha256=fuUGPE0CmD0KfQaGx4pmMnL2I6lTZxrVIb-LebQLNkw,47
|
|
16
|
+
moodle_cli-0.1.0b0.dist-info/licenses/LICENSE,sha256=mNchh_7Qge5KyljHGnGDu3I6eiVijuU22NxtZp5Dgh0,1064
|
|
17
|
+
moodle_cli-0.1.0b0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bunizao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|