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
openmd/backends/x.py ADDED
@@ -0,0 +1,455 @@
1
+ """X/Twitter-specific opt-in runtime backend providers.
2
+
3
+ This module is loaded by ``openmd.backends.registry`` through the platform
4
+ extension naming convention. It wraps read-only OpenCLI ``twitter`` commands
5
+ behind ``--enable-browser-backends`` and intentionally excludes media download,
6
+ ASR, account mutation, cookies, twscrape, proxies, and raw stdout/stderr
7
+ persistence.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import shutil
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+ from urllib.parse import urlparse
17
+
18
+ from openmd.backends.base import (
19
+ BackendCapability,
20
+ BackendDescriptor,
21
+ BackendExtractRequest,
22
+ BackendHealth,
23
+ )
24
+ from openmd.backends.executor import BackendProviderRegistry
25
+ from openmd.backends.registry import BackendRuntimeOptions
26
+ from openmd.core.models import ContentItem, TranscriptResult
27
+ from openmd.core.secrets import sanitize_error_summary, sanitize_url
28
+
29
+ from .youtube_discovery import _run_opencli
30
+
31
+
32
+ OPENCLI_TWITTER_THREAD_BACKEND = "opencli.twitter.thread"
33
+ OPENCLI_TWITTER_TWEETS_BACKEND = "opencli.twitter.tweets"
34
+ OPENCLI_TWITTER_ARTICLE_BACKEND = "opencli.twitter.article"
35
+
36
+ DEFAULT_THREAD_LIMIT = 50
37
+ DEFAULT_TWEETS_LIMIT = 20
38
+ MAX_X_TEXT_LIMIT = 100
39
+
40
+ OpenCliRunner = Callable[..., tuple[int, str, str]]
41
+
42
+ X_STATUS_RE = re.compile(r"/status(?:es)?/(\d+)")
43
+ USERNAME_RE = re.compile(r"@?([A-Za-z0-9_]{1,15})$")
44
+ RESERVED_X_PATHS = frozenset(
45
+ {
46
+ "compose",
47
+ "explore",
48
+ "home",
49
+ "i",
50
+ "intent",
51
+ "messages",
52
+ "notifications",
53
+ "search",
54
+ "settings",
55
+ "share",
56
+ }
57
+ )
58
+
59
+
60
+ def register_backend_providers(
61
+ registry: BackendProviderRegistry,
62
+ options: BackendRuntimeOptions,
63
+ ) -> None:
64
+ """Register X text providers only after explicit browser-backend opt-in."""
65
+
66
+ if not options.enable_browser_backends:
67
+ return
68
+
69
+ requested = options.backend_override
70
+ if requested in {None, OPENCLI_TWITTER_THREAD_BACKEND}:
71
+ registry.register(
72
+ OPENCLI_TWITTER_THREAD_BACKEND,
73
+ OpenCliTwitterTextProvider(default_backend=OPENCLI_TWITTER_THREAD_BACKEND),
74
+ )
75
+ if requested in {None, OPENCLI_TWITTER_TWEETS_BACKEND}:
76
+ registry.register(
77
+ OPENCLI_TWITTER_TWEETS_BACKEND,
78
+ OpenCliTwitterTextProvider(default_backend=OPENCLI_TWITTER_TWEETS_BACKEND),
79
+ )
80
+ if requested in {None, OPENCLI_TWITTER_ARTICLE_BACKEND}:
81
+ registry.register(
82
+ OPENCLI_TWITTER_ARTICLE_BACKEND,
83
+ OpenCliTwitterTextProvider(default_backend=OPENCLI_TWITTER_ARTICLE_BACKEND),
84
+ )
85
+
86
+
87
+ def build_opencli_twitter_thread_args(tweet_id: str, *, limit: int) -> list[str]:
88
+ return [
89
+ "opencli",
90
+ "twitter",
91
+ "thread",
92
+ tweet_id,
93
+ "--limit",
94
+ str(_bounded_limit(limit, default=DEFAULT_THREAD_LIMIT)),
95
+ "-f",
96
+ "md",
97
+ *_browser_options(),
98
+ ]
99
+
100
+
101
+ def build_opencli_twitter_tweets_args(username: str, *, limit: int) -> list[str]:
102
+ return [
103
+ "opencli",
104
+ "twitter",
105
+ "tweets",
106
+ username,
107
+ "--limit",
108
+ str(_bounded_limit(limit, default=DEFAULT_TWEETS_LIMIT)),
109
+ "-f",
110
+ "md",
111
+ *_browser_options(),
112
+ ]
113
+
114
+
115
+ def build_opencli_twitter_article_args(tweet_ref: str) -> list[str]:
116
+ return [
117
+ "opencli",
118
+ "twitter",
119
+ "article",
120
+ tweet_ref,
121
+ "-f",
122
+ "md",
123
+ *_browser_options(),
124
+ ]
125
+
126
+
127
+ def classify_twitter_failure(message: object) -> str:
128
+ """Map known X/OpenCLI read blockers to stable failure codes."""
129
+
130
+ text = str(message or "").lower()
131
+ if "timed out" in text or "timeout" in text:
132
+ return "opencli_twitter_timeout"
133
+ if (
134
+ "login" in text
135
+ or "sign in" in text
136
+ or "auth" in text
137
+ or "cookie" in text
138
+ or "not logged in" in text
139
+ ):
140
+ return "x_auth_required"
141
+ if "rate limit" in text or "too many requests" in text or "429" in text:
142
+ return "x_rate_limited"
143
+ if "not found" in text or "does not exist" in text or "doesn't exist" in text or "404" in text:
144
+ return "x_not_found"
145
+ if "protected" in text or "private" in text or "forbidden" in text or "not authorized" in text:
146
+ return "x_access_denied"
147
+ if "empty" in text or "no tweets" in text or "no content" in text:
148
+ return "x_empty_result"
149
+ return "opencli_twitter_failed"
150
+
151
+
152
+ class OpenCliTwitterTextProvider:
153
+ """Read X/Twitter post, thread, article, or user text through OpenCLI."""
154
+
155
+ def __init__(
156
+ self,
157
+ *,
158
+ default_backend: str = OPENCLI_TWITTER_THREAD_BACKEND,
159
+ runner: OpenCliRunner | None = None,
160
+ timeout: int = 180,
161
+ ) -> None:
162
+ self.name = default_backend
163
+ self._runner = runner or _run_opencli
164
+ self.timeout = timeout
165
+
166
+ def descriptor(self) -> BackendDescriptor:
167
+ capability = BackendCapability(
168
+ platforms=("x",),
169
+ source_types=("article", "mixed"),
170
+ input_types=("single", "batch", "collection"),
171
+ source_levels=("native_text", "partial_extraction"),
172
+ requires_network=True,
173
+ requires_credentials=False,
174
+ optional_config=("limit", "browser_session"),
175
+ )
176
+ return BackendDescriptor(
177
+ name=self.name,
178
+ layer="L3 browser/session fallback",
179
+ capabilities=(capability,),
180
+ description="Read-only X/Twitter text extraction through OpenCLI twitter commands.",
181
+ provider="opencli.twitter",
182
+ )
183
+
184
+ def health(self) -> BackendHealth:
185
+ if self._runner is not _run_opencli:
186
+ return BackendHealth(ok=True, dependencies={"opencli": "injected"})
187
+ if shutil.which("opencli") is None and shutil.which("opencli.cmd") is None:
188
+ return BackendHealth(
189
+ ok=False,
190
+ dependencies={"opencli": "missing"},
191
+ error_code="backend_dependency_missing",
192
+ error_summary="opencli executable is not available on PATH.",
193
+ )
194
+ return BackendHealth(ok=True, dependencies={"opencli": "available"})
195
+
196
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
197
+ item = request.item
198
+ requested_backend = request.primary_backend
199
+ if item.platform != "x":
200
+ return _failed(
201
+ requested_backend,
202
+ "unsupported_platform",
203
+ f"OpenCLI twitter provider only supports x, got {item.platform}.",
204
+ )
205
+
206
+ command_plan = _command_plan_for(item, requested_backend)
207
+ if command_plan.error_code is not None:
208
+ return _failed(requested_backend, command_plan.error_code, command_plan.error_summary)
209
+
210
+ exit_code, stdout, stderr = self._runner(command_plan.args, timeout=self.timeout)
211
+ if exit_code != 0:
212
+ signal = stderr or stdout or f"OpenCLI exited with code {exit_code}."
213
+ return _failed(command_plan.backend, classify_twitter_failure(signal), signal)
214
+
215
+ text = stdout.strip()
216
+ if not text:
217
+ return _failed(
218
+ command_plan.backend,
219
+ "x_empty_result",
220
+ "OpenCLI twitter command returned no Markdown text.",
221
+ )
222
+
223
+ metadata: dict[str, Any] = {"command": command_plan.command_name}
224
+ metadata.update(command_plan.metadata)
225
+ table_title, table_author = _table_post_title(text, tweet_id=metadata.get("tweet_id"))
226
+ fallback = f"X-{metadata['tweet_id']}" if metadata.get("tweet_id") else f"X-@{_x_username_candidate(item) or 'posts'}"
227
+ return TranscriptResult(
228
+ text=text.rstrip() + "\n",
229
+ status="verified",
230
+ source_level="native_text",
231
+ backend=command_plan.backend,
232
+ title=_markdown_title(text) or item.title or table_title or fallback,
233
+ author=table_author or item.author,
234
+ source_notes=[
235
+ f"OpenCLI twitter {command_plan.command_name} returned read-only X text.",
236
+ "Provider is registered only when --enable-browser-backends is enabled.",
237
+ "No follow/unfollow/post/list/account mutation was attempted.",
238
+ "No media download, raw media persistence, cookie export, proxy, twscrape, or ASR action was attempted.",
239
+ ],
240
+ metadata=metadata,
241
+ )
242
+
243
+
244
+ class _CommandPlan:
245
+ def __init__(
246
+ self,
247
+ *,
248
+ backend: str,
249
+ command_name: str,
250
+ args: list[str],
251
+ metadata: dict[str, Any] | None = None,
252
+ error_code: str | None = None,
253
+ error_summary: str | None = None,
254
+ ) -> None:
255
+ self.backend = backend
256
+ self.command_name = command_name
257
+ self.args = args
258
+ self.metadata = metadata or {}
259
+ self.error_code = error_code
260
+ self.error_summary = error_summary
261
+
262
+
263
+ def _command_plan_for(item: ContentItem, requested_backend: str) -> _CommandPlan:
264
+ if requested_backend == OPENCLI_TWITTER_ARTICLE_BACKEND:
265
+ tweet_ref = _tweet_ref_for_article(item)
266
+ if not tweet_ref:
267
+ return _error_plan("invalid_input", "X article tweet ID or URL not found.")
268
+ tweet_id = _extract_x_post_id(tweet_ref)
269
+ metadata: dict[str, Any] = {}
270
+ if tweet_id:
271
+ metadata["tweet_id"] = tweet_id
272
+ return _CommandPlan(
273
+ backend=OPENCLI_TWITTER_ARTICLE_BACKEND,
274
+ command_name="article",
275
+ args=build_opencli_twitter_article_args(tweet_id or tweet_ref),
276
+ metadata=metadata,
277
+ )
278
+
279
+ if requested_backend == OPENCLI_TWITTER_TWEETS_BACKEND:
280
+ username = _x_username_candidate(item)
281
+ if not username:
282
+ return _error_plan("invalid_input", "X username not found for tweets extraction.")
283
+ limit = _item_limit(item, default=DEFAULT_TWEETS_LIMIT)
284
+ return _CommandPlan(
285
+ backend=OPENCLI_TWITTER_TWEETS_BACKEND,
286
+ command_name="tweets",
287
+ args=build_opencli_twitter_tweets_args(username, limit=limit),
288
+ metadata={"username": username, "limit": _bounded_limit(limit, default=DEFAULT_TWEETS_LIMIT)},
289
+ )
290
+
291
+ tweet_id = _tweet_id_candidate(item)
292
+ if tweet_id:
293
+ limit = _item_limit(item, default=DEFAULT_THREAD_LIMIT)
294
+ return _CommandPlan(
295
+ backend=OPENCLI_TWITTER_THREAD_BACKEND,
296
+ command_name="thread",
297
+ args=build_opencli_twitter_thread_args(tweet_id, limit=limit),
298
+ metadata={"tweet_id": tweet_id, "limit": _bounded_limit(limit, default=DEFAULT_THREAD_LIMIT)},
299
+ )
300
+
301
+ username = _x_username_candidate(item)
302
+ if username:
303
+ limit = _item_limit(item, default=DEFAULT_TWEETS_LIMIT)
304
+ return _CommandPlan(
305
+ backend=OPENCLI_TWITTER_TWEETS_BACKEND,
306
+ command_name="tweets",
307
+ args=build_opencli_twitter_tweets_args(username, limit=limit),
308
+ metadata={"username": username, "limit": _bounded_limit(limit, default=DEFAULT_TWEETS_LIMIT)},
309
+ )
310
+
311
+ return _error_plan("invalid_input", "X post ID, article URL, or username not found.")
312
+
313
+
314
+ def _error_plan(code: str, summary: str) -> _CommandPlan:
315
+ return _CommandPlan(
316
+ backend=OPENCLI_TWITTER_THREAD_BACKEND,
317
+ command_name="none",
318
+ args=[],
319
+ error_code=code,
320
+ error_summary=summary,
321
+ )
322
+
323
+
324
+ def _tweet_id_candidate(item: ContentItem) -> str | None:
325
+ for value in (item.id, item.url):
326
+ if not value:
327
+ continue
328
+ if value.isdigit():
329
+ return value
330
+ tweet_id = _extract_x_post_id(value)
331
+ if tweet_id:
332
+ return tweet_id
333
+ return None
334
+
335
+
336
+ def _tweet_ref_for_article(item: ContentItem) -> str | None:
337
+ tweet_id = _tweet_id_candidate(item)
338
+ if tweet_id:
339
+ return tweet_id
340
+ parsed = urlparse(item.url)
341
+ if parsed.scheme and parsed.netloc and _is_x_host(parsed.netloc):
342
+ return sanitize_url(item.url)
343
+ return None
344
+
345
+
346
+ def _extract_x_post_id(value: str) -> str | None:
347
+ parsed = urlparse(value)
348
+ match = X_STATUS_RE.search(parsed.path)
349
+ return match.group(1) if match else None
350
+
351
+
352
+ def _x_username_candidate(item: ContentItem) -> str | None:
353
+ for value in (item.id, item.author, item.url):
354
+ username = _extract_x_username(str(value or ""))
355
+ if username:
356
+ return username
357
+ return None
358
+
359
+
360
+ def _extract_x_username(value: str) -> str | None:
361
+ parsed = urlparse(value)
362
+ if parsed.scheme and parsed.netloc:
363
+ if not _is_x_host(parsed.netloc):
364
+ return None
365
+ parts = [part for part in parsed.path.split("/") if part]
366
+ if not parts:
367
+ return None
368
+ candidate = parts[0].removeprefix("@")
369
+ else:
370
+ candidate = value.strip().lstrip("@").split("/")[0]
371
+
372
+ if candidate.lower() in RESERVED_X_PATHS:
373
+ return None
374
+ match = USERNAME_RE.fullmatch(candidate)
375
+ return match.group(1) if match else None
376
+
377
+
378
+ def _is_x_host(host: str) -> bool:
379
+ normalized = host.lower().split(":")[0]
380
+ return normalized == "x.com" or normalized.endswith(".x.com") or normalized == "twitter.com" or normalized.endswith(".twitter.com")
381
+
382
+
383
+ def _item_limit(item: ContentItem, *, default: int) -> int:
384
+ value = item.raw_meta.get("limit", default)
385
+ if value is None:
386
+ return default
387
+ try:
388
+ return int(value)
389
+ except (TypeError, ValueError):
390
+ return default
391
+
392
+
393
+ def _bounded_limit(value: int, *, default: int) -> int:
394
+ if value <= 0:
395
+ return default
396
+ return min(value, MAX_X_TEXT_LIMIT)
397
+
398
+
399
+ def _browser_options() -> list[str]:
400
+ return [
401
+ "--window",
402
+ "background",
403
+ "--site-session",
404
+ "persistent",
405
+ "--keep-tab",
406
+ "false",
407
+ ]
408
+
409
+
410
+ def _markdown_title(markdown: str) -> str | None:
411
+ for line in markdown.splitlines():
412
+ stripped = line.strip()
413
+ if stripped.startswith("# "):
414
+ return stripped.removeprefix("# ").strip() or None
415
+ return None
416
+
417
+
418
+ def _table_post_title(markdown: str, *, tweet_id: str | None) -> tuple[str | None, str | None]:
419
+ """Read metadata from the Markdown table without changing its raw body."""
420
+ headers: list[str] = []
421
+ for line in markdown.splitlines():
422
+ if not line.lstrip().startswith("|"):
423
+ continue
424
+ cells = [cell.strip().replace(r"\|", "|") for cell in re.split(r"(?<!\\)\|", line.strip().strip("|"))]
425
+ if "author" in cells and "text" in cells:
426
+ headers = cells
427
+ continue
428
+ if not headers or len(cells) < len(headers):
429
+ continue
430
+ row = dict(zip(headers, cells))
431
+ if tweet_id and row.get("id") != tweet_id:
432
+ continue
433
+ body = re.sub(r"<br\s*/?>|\\n", " ", row["text"], flags=re.IGNORECASE)
434
+ body = " ".join(body.split())
435
+ if not body or re.fullmatch(r"[-: ]+", body):
436
+ continue
437
+ author = row["author"] or None
438
+ opening = body[:56] + ("…" if len(body) > 56 else "")
439
+ return f"{author}:{opening}" if author else opening, author
440
+ return None, None
441
+
442
+
443
+ def _failed(backend: str, code: str, error: object) -> TranscriptResult:
444
+ return TranscriptResult(
445
+ text="",
446
+ status="failed",
447
+ source_level="partial_extraction",
448
+ backend=backend,
449
+ source_notes=[
450
+ "OpenCLI twitter provider returned structured failure.",
451
+ "No follow/unfollow/post/list/account mutation, media download, proxy, twscrape, or ASR action was attempted.",
452
+ ],
453
+ error_code=code,
454
+ error_summary=sanitize_error_summary(error),
455
+ )