openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,426 @@
1
+ """Discourse engine reader: public JSON, then an owned same-origin browser lease."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+ import uuid
10
+ import time
11
+ from dataclasses import dataclass
12
+ from html.parser import HTMLParser
13
+ from urllib.parse import urlencode, urljoin, urlparse
14
+
15
+ from openmd.core.content_validation import validate_extraction_content
16
+ from openmd.core.models import TranscriptResult
17
+ from openmd.core.opencli_output import load_opencli_json
18
+
19
+
20
+ DISCOURSE_BACKEND = "web_article.discourse"
21
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TopicTarget:
26
+ origin: str
27
+ prefix: str
28
+ topic_id: int
29
+ post_number: int
30
+
31
+ @property
32
+ def json_path(self):
33
+ floor = f"/{self.post_number}" if self.post_number != 1 else ""
34
+ return f"{self.prefix}/t/{self.topic_id}{floor}.json?include_raw=true"
35
+
36
+
37
+ def topic_target(url: str) -> TopicTarget | None:
38
+ parsed = urlparse(url)
39
+ match = re.fullmatch(r"(.*?)/t/(?:[^/]+/)?(\d+)(?:/(\d+))?/?", parsed.path)
40
+ # Greedy optional slug can swallow numeric IDs in /t/id/floor.
41
+ parts = parsed.path.strip("/").split("/")
42
+ if "t" not in parts:
43
+ return None
44
+ index = parts.index("t")
45
+ tail = parts[index + 1:]
46
+ if tail and tail[0].isdigit():
47
+ numbers = tail
48
+ elif len(tail) >= 2 and tail[1].isdigit():
49
+ numbers = tail[1:]
50
+ else:
51
+ return None
52
+ if not match or len(numbers) not in {1, 2} or any(not value.isdigit() for value in numbers):
53
+ return None
54
+ return TopicTarget(f"{parsed.scheme}://{parsed.netloc}", "/" + "/".join(parts[:index]) if index else "", int(numbers[0]), int(numbers[1]) if len(numbers) == 2 else 1)
55
+
56
+
57
+ def discourse_html_evidence(body: str) -> bool:
58
+ parser = _EngineMarkers()
59
+ parser.feed(body)
60
+ return parser.generator or (parser.application and parser.setup)
61
+
62
+
63
+ class _EngineMarkers(HTMLParser):
64
+ generator = False
65
+ application = False
66
+ setup = False
67
+
68
+ def handle_starttag(self, tag, attrs):
69
+ attrs = dict(attrs)
70
+ if tag == "meta" and attrs.get("name", "").lower() == "generator" and "discourse" in attrs.get("content", "").lower():
71
+ self.generator = True
72
+ if attrs.get("id") == "discourse-application":
73
+ self.application = True
74
+ if "data-discourse-setup" in attrs or attrs.get("id") == "data-preloaded":
75
+ self.setup = True
76
+
77
+
78
+ def _public_fetch(url: str) -> tuple[int, object]:
79
+ request = urllib.request.Request(url, headers={"Accept": "application/json,text/html;q=0.5", "User-Agent": "Openmd/0.0.1"})
80
+ try:
81
+ response = urllib.request.urlopen(request, timeout=30)
82
+ except urllib.error.HTTPError as exc:
83
+ response = exc
84
+ with response:
85
+ data = response.read(MAX_RESPONSE_BYTES + 1)
86
+ if len(data) > MAX_RESPONSE_BYTES:
87
+ raise ValueError("discourse_response_oversized")
88
+ body = data.decode("utf-8", errors="replace")
89
+ try:
90
+ body = json.loads(body)
91
+ except ValueError:
92
+ pass
93
+ return response.code, body
94
+
95
+
96
+ class BrowserJsonLease:
97
+ """Read JSON in the page's origin; release only this named session."""
98
+
99
+ def __init__(self, url: str, *, runner=None):
100
+ self.url = url
101
+ if runner is None:
102
+ # Loaded lazily: web_article registers this provider and already
103
+ # owns the resolved executable / process-lifetime wrapper.
104
+ from openmd.backends.web_article import _run_command
105
+
106
+ def runner(command, *, timeout):
107
+ return _run_command(command, timeout)
108
+ self.runner = runner
109
+ self.session = "openmd-discourse-" + uuid.uuid4().hex
110
+ self.opened = False
111
+ self.engine = False
112
+
113
+ def read(self, path: str) -> tuple[int, object]:
114
+ if not self.opened:
115
+ self.opened = True
116
+ code, _stdout, _stderr = self.runner(["opencli", "browser", "open", self.url, "--session", self.session, "--window", "background"], timeout=90)
117
+ if code:
118
+ raise ValueError("discourse_browser_open_failed")
119
+ origin = f"{urlparse(self.url).scheme}://{urlparse(self.url).netloc}"
120
+ marker_script = """(() => {
121
+ const generator = document.querySelector('meta[name="generator"]')?.content || '';
122
+ const engine = /discourse/i.test(generator) || Boolean(document.querySelector('#discourse-application') && document.querySelector('[data-discourse-setup], #data-preloaded'));
123
+ return {engine};
124
+ })()"""
125
+ # Keep engine evidence independently of the larger JSON read. A fetch
126
+ # exception must not let later generic readers accept a known UI shell.
127
+ marker_data = self._eval(" ".join(marker_script.splitlines()))
128
+ self.engine = self.engine or marker_data.get("engine") is True
129
+ script = """(async () => {
130
+ if (location.origin !== ORIGIN) return {status: 403, payload: null};
131
+ try {
132
+ const response = await fetch(PATH, {credentials: 'same-origin', headers: {'Accept':'application/json'}});
133
+ const body = await response.text();
134
+ if (body.length > 16000000) return {status: 413, payload: null};
135
+ let payload = null; try { payload = JSON.parse(body); } catch {}
136
+ return {status: response.status, payload};
137
+ } catch { return {status: 0, payload: null}; }
138
+ })()""".replace("ORIGIN", json.dumps(origin)).replace("PATH", json.dumps(path))
139
+ data = self._eval(" ".join(script.splitlines()))
140
+ self.engine = self.engine or data.get("engine") is True
141
+ return int(data.get("status") or 0), data.get("payload")
142
+
143
+ def _eval(self, script):
144
+ code, stdout, _stderr = self.runner(["opencli", "browser", "eval", script, "--session", self.session, "--window", "background"], timeout=90)
145
+ if code:
146
+ raise ValueError("discourse_browser_read_failed")
147
+ data = load_opencli_json(stdout)
148
+ if isinstance(data, str):
149
+ data = json.loads(data)
150
+ if not isinstance(data, dict):
151
+ raise ValueError("discourse_browser_invalid_result")
152
+ return data
153
+
154
+ def close(self):
155
+ if self.opened:
156
+ try:
157
+ self.runner(["opencli", "browser", "close", "--session", self.session, "--window", "background"], timeout=30)
158
+ except Exception:
159
+ pass
160
+
161
+
162
+ class DiscourseProvider:
163
+ name = DISCOURSE_BACKEND
164
+
165
+ def __init__(self, options, *, fetcher=None, browser_factory=None, scope: str = "target_post", chunk_size: int = 20):
166
+ self.options = options
167
+ self.fetcher = fetcher or _public_fetch
168
+ self.browser_factory = browser_factory or BrowserJsonLease
169
+ self.scope = scope
170
+ self.chunk_size = max(1, min(50, chunk_size))
171
+
172
+ def extract(self, request):
173
+ target = topic_target(request.item.url)
174
+ if target is None:
175
+ return self._failed("discourse_not_applicable", "The URL has no topic selector.", engine=False)
176
+ browser = self.browser_factory(request.item.url) if self.options.enable_browser_backends else None
177
+ detected = request.item.raw_meta.get("site_engine") == "discourse"
178
+ branch = "public_json"
179
+ request_count = 0
180
+ chunk_errors = []
181
+ def read(path):
182
+ nonlocal detected, branch, request_count
183
+ request_count += 1
184
+ status, payload = 0, None
185
+ if self.options.enable_network_backends:
186
+ try:
187
+ status, payload = self.fetcher(target.origin + path)
188
+ if isinstance(payload, str):
189
+ detected = detected or discourse_html_evidence(payload)
190
+ except Exception:
191
+ pass
192
+ if request_count == 1 and not detected and not _posts_payload(payload) and self.options.enable_network_backends:
193
+ try:
194
+ _html_status, page = self.fetcher(request.item.url)
195
+ detected = isinstance(page, str) and discourse_html_evidence(page)
196
+ except Exception:
197
+ pass
198
+ if status == 429:
199
+ return status, payload
200
+ if not _posts_payload(payload) and browser is not None:
201
+ status, payload = browser.read(path)
202
+ detected = detected or browser.engine
203
+ branch = "browser_same_origin"
204
+ if _posts_payload(payload):
205
+ detected = True
206
+ return status, payload
207
+ try:
208
+ status, payload = read(target.json_path)
209
+ if not detected and self.options.enable_network_backends:
210
+ try:
211
+ _html_status, page = self.fetcher(request.item.url)
212
+ detected = isinstance(page, str) and discourse_html_evidence(page)
213
+ except Exception:
214
+ pass
215
+ if not detected:
216
+ return self._failed("discourse_not_detected", "No Discourse engine evidence was found.", engine=False)
217
+ if not _posts_payload(payload):
218
+ return self._failed(_access_code(status), "The detected Discourse engine did not return accessible post JSON.")
219
+ if payload.get("id") is not None and int(payload["id"]) != target.topic_id:
220
+ return self._failed("discourse_topic_id_mismatch", "The topic JSON does not match the requested topic ID.")
221
+ posts = {int(post["id"]): post for post in payload["post_stream"]["posts"] if isinstance(post, dict) and str(post.get("id", "")).isdigit()}
222
+ initial_count = len(posts)
223
+ all_posts = request.item.raw_meta.get("discourse_scope", self.scope) == "all_posts"
224
+ stream = [int(value) for value in payload["post_stream"].get("stream", []) if str(value).isdigit()]
225
+ if all_posts:
226
+ if not stream:
227
+ return self._failed("discourse_stream_missing", "All-post extraction requires the topic stream ID list.")
228
+ if len(stream) > 10000:
229
+ return self._failed("discourse_topic_scope_limit", "The explicit all-post scope exceeds this reader's 10000-post bound.")
230
+ remaining = [value for value in dict.fromkeys(stream) if value not in posts]
231
+ for offset in range(0, len(remaining), self.chunk_size):
232
+ if offset:
233
+ time.sleep(0.5)
234
+ chunk = remaining[offset:offset + self.chunk_size]
235
+ path = f"{target.prefix}/t/{target.topic_id}/posts.json?" + urlencode([("post_ids[]", value) for value in chunk]) + "&include_raw=true"
236
+ chunk_status, batch = read(path)
237
+ if not _posts_payload(batch):
238
+ chunk_errors.append(_access_code(chunk_status))
239
+ continue
240
+ for post in batch["post_stream"]["posts"]:
241
+ if isinstance(post, dict) and str(post.get("id", "")).isdigit() and int(post["id"]) in stream:
242
+ posts[int(post["id"])] = post
243
+ posts = {key: post for key, post in posts.items() if key in stream}
244
+ selected = sorted(posts.values(), key=lambda post: (int(post.get("post_number") or 0), int(post["id"])))
245
+ if not all_posts:
246
+ selected = [post for post in selected if post.get("post_number") == target.post_number]
247
+ if not selected:
248
+ return self._failed("discourse_target_post_missing", "The requested post number was not present in the accessible topic JSON.")
249
+ main = next((post for post in selected if post.get("post_number") == (1 if all_posts else target.post_number)), None)
250
+ if main is None:
251
+ return self._failed("discourse_target_post_missing", "The requested main/target post is not visible in the selected topic scope.")
252
+ bodies = [(post, _post_body(post, target.origin + target.prefix + "/")) for post in selected]
253
+ missing = list(dict.fromkeys([value for value in stream if value not in posts] + [int(post["id"]) for post, body in bodies if not body.strip()])) if all_posts else []
254
+ if not all_posts and not bodies[0][1].strip():
255
+ return self._failed("discourse_post_body_missing", "The requested post body is empty.")
256
+ if all_posts and not _post_body(main, target.origin + target.prefix + "/").strip():
257
+ return self._failed("discourse_post_body_missing", "The main post body is empty.")
258
+ text = "\n\n".join((f"## #{post['post_number']} {post.get('username', '')}\n\n" if all_posts else "") + body for post, body in bodies if body.strip())
259
+ metadata = {"engine": "discourse", "topic_id": target.topic_id, "post_id": main["id"], "target_post_id": main["id"], "post_body_present": bool(text.strip()), "scope": "all_posts" if all_posts else "main_post" if target.post_number == 1 else "target_post", "coverage": {"expected_count": len(set(stream)) if all_posts else 1, "fetched_count": sum(bool(body.strip()) for _, body in bodies), "missing_count": len(missing), "initial_count": initial_count, "request_count": request_count, "missing_post_ids": missing, "complete": not missing}, "strategy": branch}
260
+ return validate_extraction_content(TranscriptResult(text=text.rstrip() + "\n", status="partial" if missing else "verified", source_level="native_text", backend=self.name, title=str(payload.get("title") or request.item.title or target.topic_id), author=main.get("username"), published_at=main.get("created_at"), metadata=metadata, error_code=(chunk_errors[-1] if chunk_errors else "discourse_posts_incomplete") if missing else None, error_summary=f"{len(missing)} visible-stream post IDs could not be retrieved with a nonempty body." if missing else None, source_notes=["Discourse structured post bodies only; raw Markdown preferred, navigation and suggested topics excluded.", "Coverage is limited to posts visible to the current public/session reader."]), request.item)
261
+ except Exception:
262
+ return self._failed("discourse_read_failed", "The bounded Discourse read could not complete.", engine=detected or bool(browser and browser.engine))
263
+ finally:
264
+ if browser is not None:
265
+ browser.close()
266
+
267
+ def _failed(self, code, summary, *, engine=True):
268
+ return TranscriptResult(text="", status="failed", source_level="partial_extraction", backend=self.name, error_code=code, error_summary=summary, metadata={"engine": "discourse", "failure_class": "user_resolvable" if code in {"discourse_auth_required", "discourse_permission_denied"} else "runtime_transient"} if engine else {})
269
+
270
+
271
+ def _posts_payload(payload):
272
+ return isinstance(payload, dict) and isinstance(payload.get("post_stream"), dict) and isinstance(payload["post_stream"].get("posts"), list)
273
+
274
+
275
+ def _access_code(status):
276
+ return {401: "discourse_auth_required", 403: "discourse_permission_denied", 429: "discourse_rate_limited", 404: "discourse_topic_unavailable"}.get(status, "discourse_post_json_unavailable")
277
+
278
+
279
+ def _post_body(post, base_url):
280
+ if isinstance(post.get("raw"), str) and post["raw"].strip():
281
+ return _absolute_markdown_links(post["raw"], base_url)
282
+ cooked = post.get("cooked")
283
+ if not isinstance(cooked, str) or not cooked.strip():
284
+ return ""
285
+ parser = _CookedMarkdown(base_url)
286
+ parser.feed(cooked)
287
+ return parser.markdown()
288
+
289
+
290
+ def _absolute_markdown_links(text, base_url):
291
+ def absolute(value):
292
+ if value.startswith("upload://"):
293
+ return urljoin(base_url, "uploads/short-url/" + value.removeprefix("upload://"))
294
+ return urljoin(base_url, value)
295
+ output = []
296
+ fence = None
297
+ for line in text.splitlines(keepends=True):
298
+ marker = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
299
+ if marker:
300
+ value = marker[1]
301
+ if fence is None:
302
+ fence = value
303
+ elif value[0] == fence[0] and len(value) >= len(fence):
304
+ fence = None
305
+ output.append(line)
306
+ continue
307
+ if fence is not None or line.startswith((" ", "\t")):
308
+ output.append(line)
309
+ continue
310
+ # Inline code examples also remain literal source text.
311
+ pieces = re.split(r"(`+[^`]*`+)", line)
312
+ for index in range(0, len(pieces), 2):
313
+ pieces[index] = re.sub(r"(!?\[[^\]\n]*\]\()([^\s)]+)([^)]*\))", lambda match: match[1] + absolute(match[2]) + match[3], pieces[index])
314
+ output.append("".join(pieces))
315
+ return "".join(output)
316
+
317
+
318
+ class _CookedMarkdown(HTMLParser):
319
+ def __init__(self, base_url):
320
+ super().__init__(convert_charrefs=True)
321
+ self.base = base_url
322
+ self.parts = []
323
+ self.stack = []
324
+ self.hidden = 0
325
+ self.pre = False
326
+ self.code_fence = "```"
327
+ self.rows = []
328
+ self.row = None
329
+ self.cell = None
330
+ self.quotes = []
331
+
332
+ def emit(self, value):
333
+ if self.cell is not None:
334
+ self.cell.append(value)
335
+ else:
336
+ self.parts.append(value)
337
+
338
+ def handle_starttag(self, tag, attrs):
339
+ attrs = dict(attrs)
340
+ self.stack.append((tag, attrs))
341
+ if tag in {"script", "style"}:
342
+ self.hidden += 1
343
+ if self.hidden:
344
+ return
345
+ if tag == "table":
346
+ self.rows = []
347
+ elif tag == "tr":
348
+ self.row = []
349
+ elif tag in {"th", "td"}:
350
+ self.cell = []
351
+ elif tag == "pre":
352
+ self.pre = True
353
+ self.emit("\n\n```\n")
354
+ elif tag == "code":
355
+ if self.pre:
356
+ language = next((value.removeprefix("language-") for value in attrs.get("class", "").split() if value.startswith("language-")), "")
357
+ if language and self.parts and self.parts[-1] == "\n\n```\n":
358
+ self.parts[-1] = "\n\n```" + language + "\n"
359
+ else:
360
+ self.emit("`")
361
+ elif tag == "a":
362
+ self.emit("[")
363
+ elif tag == "img":
364
+ self.emit(f"![{attrs.get('alt', '')}]({urljoin(self.base, attrs.get('src', ''))})")
365
+ elif tag in {"strong", "b"}:
366
+ self.emit("**")
367
+ elif tag in {"em", "i"}:
368
+ self.emit("*")
369
+ elif tag == "br":
370
+ self.emit("\n")
371
+ elif re.fullmatch(r"h[1-6]", tag):
372
+ self.emit("\n\n" + "#" * int(tag[1]) + " ")
373
+ elif tag == "li":
374
+ self.emit("\n- ")
375
+ elif tag in {"p", "div", "blockquote", "aside"}:
376
+ self.emit("\n\n")
377
+ if tag in {"blockquote", "aside"}:
378
+ self.quotes.append(len(self.parts))
379
+
380
+ def handle_endtag(self, tag):
381
+ attrs = {}
382
+ for index in range(len(self.stack) - 1, -1, -1):
383
+ if self.stack[index][0] == tag:
384
+ attrs = self.stack.pop(index)[1]
385
+ break
386
+ if tag in {"script", "style"}:
387
+ self.hidden = max(0, self.hidden - 1)
388
+ return
389
+ if self.hidden:
390
+ return
391
+ if tag in {"td", "th"} and self.cell is not None:
392
+ if self.row is not None:
393
+ self.row.append("".join(self.cell).strip().replace("|", "\\|").replace("\n", "<br>"))
394
+ self.cell = None
395
+ elif tag == "tr" and self.row is not None:
396
+ self.rows.append(self.row)
397
+ self.row = None
398
+ elif tag == "table" and self.rows:
399
+ width = max(len(row) for row in self.rows)
400
+ rows = [row + [""] * (width - len(row)) for row in self.rows]
401
+ rows.insert(1, ["---"] * width)
402
+ self.emit("\n\n" + "\n".join("| " + " | ".join(row) + " |" for row in rows) + "\n\n")
403
+ elif tag == "pre":
404
+ self.emit("\n```\n\n")
405
+ self.pre = False
406
+ elif tag == "code" and not self.pre:
407
+ self.emit("`")
408
+ elif tag == "a":
409
+ self.emit("](" + urljoin(self.base, attrs.get("href", "")) + ")")
410
+ elif tag in {"strong", "b"}:
411
+ self.emit("**")
412
+ elif tag in {"em", "i"}:
413
+ self.emit("*")
414
+ elif tag in {"blockquote", "aside"} and self.quotes:
415
+ start = self.quotes.pop()
416
+ quote = "".join(self.parts[start:]).strip()
417
+ self.parts[start:] = ["\n".join("> " + line for line in quote.splitlines()) + "\n\n"]
418
+ elif tag in {"p", "div"} or re.fullmatch(r"h[1-6]", tag):
419
+ self.emit("\n\n")
420
+
421
+ def handle_data(self, data):
422
+ if not self.hidden:
423
+ self.emit(data if self.pre else re.sub(r"\s+", " ", data))
424
+
425
+ def markdown(self):
426
+ return "".join(self.parts).strip()