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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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")})")
|
|
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()
|