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
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
|
+
)
|