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,124 @@
1
+ """Bounded, transient Douyin single-work detail acquisition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import replace
7
+ from datetime import datetime, timezone
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ from openmd.backends.youtube_discovery import _run_opencli
11
+ from openmd.core.detector import extract_first_url
12
+ from openmd.core.models import ContentItem
13
+ from openmd.core.opencli_output import load_opencli_json
14
+
15
+
16
+ class DouyinDetailError(ValueError):
17
+ def __init__(self, code: str, summary: str):
18
+ super().__init__(summary)
19
+ self.code = code
20
+
21
+
22
+ def douyin_author(payload: dict) -> str | None:
23
+ """Read nickname-shaped fields without stringifying API objects or IDs."""
24
+ fields = {str(key).lower().replace("_", ""): value for key, value in payload.items()}
25
+ for key in ("author", "nickname", "authorname", "user", "username", "userinfo"):
26
+ value = fields.get(key)
27
+ if isinstance(value, dict):
28
+ value = douyin_author(value) or value.get("name")
29
+ if isinstance(value, str) and value.strip():
30
+ return value.strip()
31
+ return None
32
+
33
+
34
+ def douyin_published_at(payload: dict) -> str | None:
35
+ """Keep source calendar times; normalize Unix seconds/milliseconds in UTC."""
36
+ fields = {str(key).lower().replace("_", ""): value for key, value in payload.items()}
37
+ for key in ("publishedat", "createtime", "created", "createdat", "publishtime", "date"):
38
+ value = fields.get(key)
39
+ if not isinstance(value, (str, int, float)) or isinstance(value, bool):
40
+ continue
41
+ text = str(value).strip()
42
+ if not text:
43
+ continue
44
+ try:
45
+ if re.fullmatch(r"\d{8}", text):
46
+ return datetime.strptime(text, "%Y%m%d").date().isoformat()
47
+ if re.fullmatch(r"\d{10}(?:\.\d+)?|\d{13}(?:\.\d+)?", text):
48
+ seconds = float(text) / (1000 if len(text.split(".")[0]) == 13 else 1)
49
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
50
+ datetime.fromisoformat(text.replace("Z", "+00:00"))
51
+ return text
52
+ except (ValueError, OverflowError, OSError):
53
+ continue
54
+ return None
55
+
56
+
57
+ def douyin_video_id(value: str) -> str | None:
58
+ target = extract_first_url(value)
59
+ if re.fullmatch(r"\d{6,20}", target):
60
+ return target
61
+ parsed = urlparse(target if "://" in target else "https://" + target)
62
+ host = (parsed.hostname or "").lower()
63
+ if host != "douyin.com" and not host.endswith(".douyin.com") and host != "iesdouyin.com" and not host.endswith(".iesdouyin.com"):
64
+ return None
65
+ for key in ("modal_id", "aweme_id", "item_id"):
66
+ candidate = parse_qs(parsed.query).get(key, [""])[0]
67
+ if re.fullmatch(r"\d{6,20}", candidate):
68
+ return candidate
69
+ match = re.search(r"/(?:video|note)/(\d{6,20})(?:/|$)", parsed.path)
70
+ return match.group(1) if match else None
71
+
72
+
73
+ class DouyinDetailProvider:
74
+ name = "opencli.douyin.detail"
75
+
76
+ def __init__(self, *, runner=None, timeout: int = 90):
77
+ self.runner = runner or _run_opencli
78
+ self.timeout = timeout
79
+
80
+ def resolve(self, item: ContentItem) -> ContentItem:
81
+ target = extract_first_url(item.url)
82
+ expected = douyin_video_id(target) or (item.id if item.id and item.id.isdigit() else None)
83
+ parsed = urlparse(target)
84
+ if not expected and (parsed.hostname or "").lower() not in {"v.douyin.com", "www.iesdouyin.com"}:
85
+ raise DouyinDetailError("douyin_invalid_video_target", "A video ID, video URL, modal URL, or video share link is required.")
86
+ command = ["opencli", "douyin", "detail", expected or target, "--window", "background", "--keep-tab", "false", "-f", "json"]
87
+ try:
88
+ code, stdout, _stderr = self.runner(command, timeout=self.timeout)
89
+ except Exception as exc:
90
+ raise DouyinDetailError("douyin_detail_read_failed", "The bounded video detail command could not complete.") from exc
91
+ if code:
92
+ raise DouyinDetailError("douyin_detail_read_failed", "The bounded video detail command failed; check the browser session or access permissions.")
93
+ try:
94
+ payload = load_opencli_json(stdout)
95
+ if isinstance(payload, list) and len(payload) == 1:
96
+ payload = payload[0]
97
+ if not isinstance(payload, dict) or payload.get("ok") is False:
98
+ raise ValueError("invalid detail")
99
+ video_id = str(payload.get("aweme_id") or payload.get("id") or "")
100
+ if not re.fullmatch(r"\d{6,20}", video_id):
101
+ raise ValueError("missing identity")
102
+ except (ValueError, TypeError) as exc:
103
+ raise DouyinDetailError("douyin_detail_invalid", "Video detail returned no valid single-work identity.") from exc
104
+ if expected and video_id != expected:
105
+ raise DouyinDetailError("douyin_detail_id_mismatch", "Video detail did not match the requested work ID.")
106
+ images = payload.get("images")
107
+ is_image = bool(images) or payload.get("media_kind") == "image_text" or payload.get("aweme_type") in {2, 68, 150}
108
+ raw_meta = {**item.raw_meta, "detail_resolved": True, "identity_unresolved": False, "content_id": video_id}
109
+ raw_meta.update({"duration": payload.get("duration_s", payload.get("duration", 0)), "media_kind": "image_text" if is_image else "video"})
110
+ if is_image:
111
+ # Image-post descriptions are author text, distinct from video
112
+ # captions. Keep an empty value too: a missing description must not
113
+ # fall back to an ID or a separately supplied display title.
114
+ raw_meta["author_text"] = payload.get("desc") if isinstance(payload.get("desc"), str) else ""
115
+ raw_meta["author_text_source"] = "detail_desc"
116
+ for key in ("play_url", "transcript_candidates", "subtitles", "caption_info", "captionInfo"):
117
+ if payload.get(key):
118
+ raw_meta[key] = payload[key]
119
+ # A video's description remains title metadata, never a transcript.
120
+ return replace(item, url=f"https://www.douyin.com/video/{video_id}", id=video_id,
121
+ title=item.title if item.title and item.title != video_id else str(payload.get("desc") or payload.get("title") or video_id),
122
+ author=item.author or douyin_author(payload),
123
+ published_at=item.published_at or douyin_published_at(payload),
124
+ source_type="image_text" if is_image else "video", raw_meta=raw_meta)