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/console.py ADDED
@@ -0,0 +1,32 @@
1
+ """Keep the caller's console encoding; degrade only unsupported characters."""
2
+ from __future__ import annotations
3
+
4
+ import codecs
5
+ import sys
6
+
7
+
8
+ _SYMBOLS = {"✓": "[OK]", "✔": "[OK]", "✗": "[X]", "✘": "[X]", "·": "-", "→": "->", "…": "..."}
9
+
10
+
11
+ def _encoding_fallback(error: UnicodeEncodeError) -> tuple[str, int]:
12
+ if not isinstance(error, UnicodeEncodeError):
13
+ raise error
14
+ return "".join(
15
+ _SYMBOLS.get(char, char.encode("ascii", "backslashreplace").decode("ascii"))
16
+ for char in error.object[error.start:error.end]
17
+ ), error.end
18
+
19
+
20
+ def configure_console() -> None:
21
+ """Also covers argparse and errors written directly to stdout/stderr.
22
+
23
+ Chinese stays Chinese on cp936. Unsupported text uses visible escapes,
24
+ while common status symbols get ASCII words. JSON callers use ensure_ascii
25
+ so neither those substitutions nor non-BMP escapes alter JSON values.
26
+ In-memory capture streams without reconfigure need no encoding policy.
27
+ """
28
+ codecs.register_error("openmd_console", _encoding_fallback)
29
+ for stream in (sys.stdout, sys.stderr):
30
+ reconfigure = getattr(stream, "reconfigure", None)
31
+ if callable(reconfigure):
32
+ reconfigure(errors="openmd_console")
@@ -0,0 +1 @@
1
+ """Core Openmd models and utilities."""
@@ -0,0 +1,162 @@
1
+ """Read-only V1 capability projections backed by current registry declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from openmd.routing.loader import default_backend_chains, effective_routes, load_catalog
8
+
9
+
10
+ CAPABILITY_TIERS = frozenset({"A", "B", "C", "D"})
11
+ CAPABILITY_STATES = frozenset({"ready", "partial", "blocked", "experimental", "paused"})
12
+ BINDING_STATES = frozenset({"implemented", "unimplemented"})
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CapabilityRecord:
17
+ source_id: str
18
+ tier: str
19
+ state: str
20
+ input_kinds: tuple[str, ...]
21
+ source_exhausted_kinds: tuple[str, ...]
22
+ profiles: tuple[str, ...]
23
+ planned_provider: str | None
24
+ runtime_provider: str | None
25
+ binding_state: str
26
+ required_gates: tuple[str, ...]
27
+ cost_class: str
28
+ execution_ready: bool
29
+ blocked_reason: str | None
30
+ card_id: str | None = None
31
+ route_version: str | None = None
32
+ overlay_applied: bool = False
33
+
34
+ def as_dict(self) -> dict[str, object]:
35
+ return {
36
+ "source_id": self.source_id,
37
+ "tier": self.tier,
38
+ "state": self.state,
39
+ "input_kinds": list(self.input_kinds),
40
+ "source_exhausted_kinds": list(self.source_exhausted_kinds),
41
+ "profiles": list(self.profiles),
42
+ "planned_provider": self.planned_provider,
43
+ "runtime_provider": self.runtime_provider,
44
+ "binding_state": self.binding_state,
45
+ "required_gates": list(self.required_gates),
46
+ "cost_class": self.cost_class,
47
+ "execution_ready": self.execution_ready,
48
+ "blocked_reason": self.blocked_reason,
49
+ "card_id": self.card_id,
50
+ "route_version": self.route_version,
51
+ "overlay_applied": self.overlay_applied,
52
+ }
53
+
54
+
55
+ def _capability(
56
+ source_id: str,
57
+ tier: str,
58
+ state: str,
59
+ input_kinds: tuple[str, ...],
60
+ source_exhausted_kinds: tuple[str, ...],
61
+ profiles: tuple[str, ...],
62
+ *,
63
+ platform: str | None = None,
64
+ planned_provider: str | None = None,
65
+ runtime_provider: str | None = None,
66
+ required_gates: tuple[str, ...] = (),
67
+ cost_class: str = "unknown",
68
+ card_id: str | None = None,
69
+ overlay_applied: bool = False,
70
+ ) -> CapabilityRecord:
71
+ planned = planned_provider
72
+ binding_state = "implemented" if runtime_provider is not None else "unimplemented"
73
+ blocked_reason: str | None = None
74
+ if state == "paused":
75
+ blocked_reason = "capability_paused"
76
+ elif binding_state == "unimplemented":
77
+ blocked_reason = "runtime_provider_unimplemented"
78
+ elif required_gates:
79
+ blocked_reason = "required_gate_missing"
80
+ return CapabilityRecord(
81
+ source_id=source_id,
82
+ tier=tier,
83
+ state=state,
84
+ input_kinds=input_kinds,
85
+ source_exhausted_kinds=source_exhausted_kinds,
86
+ profiles=profiles,
87
+ planned_provider=planned,
88
+ runtime_provider=runtime_provider,
89
+ binding_state=binding_state,
90
+ required_gates=required_gates,
91
+ cost_class=cost_class,
92
+ execution_ready=state not in {"paused", "blocked"} and binding_state == "implemented" and not required_gates,
93
+ blocked_reason=blocked_reason,
94
+ card_id=card_id,
95
+ route_version=load_catalog().version,
96
+ overlay_applied=overlay_applied,
97
+ )
98
+
99
+
100
+ def _project_capabilities(*, effective: bool = True) -> tuple[CapabilityRecord, ...]:
101
+ catalog = load_catalog()
102
+ chains = default_backend_chains(effective=effective)
103
+ records: list[CapabilityRecord] = []
104
+ for metadata in catalog.capability_metadata:
105
+ source = metadata["route_source"]
106
+ planned = chains[source][0] if source in chains else metadata["planned_provider"]
107
+ binding = metadata.get("runtime_binding")
108
+ runtime = planned if binding == "route" else binding
109
+ card = catalog.capabilities.get(runtime or planned or "", {})
110
+ records.append(_capability(
111
+ metadata["source_id"], metadata["tier"], metadata["state"],
112
+ tuple(metadata["input_kinds"]), tuple(metadata["source_exhausted_kinds"]), tuple(metadata["profiles"]),
113
+ planned_provider=planned, runtime_provider=runtime,
114
+ required_gates=tuple(card.get("gates", [])), cost_class=card.get("cost_class", "unknown"),
115
+ card_id=card.get("card_id"),
116
+ overlay_applied=effective and any(route["overlay_applied"] for route in effective_routes(source)),
117
+ ))
118
+ return tuple(records)
119
+
120
+
121
+ APPROVED_CAPABILITIES: tuple[CapabilityRecord, ...] = _project_capabilities(effective=False)
122
+
123
+
124
+ def validate_capability_manifest(
125
+ records: tuple[CapabilityRecord, ...] = APPROVED_CAPABILITIES,
126
+ ) -> tuple[CapabilityRecord, ...]:
127
+ seen: set[str] = set()
128
+ for record in records:
129
+ if not record.source_id or record.source_id in seen:
130
+ raise ValueError("capability source_id is duplicated or empty")
131
+ seen.add(record.source_id)
132
+ if record.tier not in CAPABILITY_TIERS:
133
+ raise ValueError(f"invalid capability tier: {record.source_id}")
134
+ if record.state not in CAPABILITY_STATES:
135
+ raise ValueError(f"invalid capability state: {record.source_id}")
136
+ if record.binding_state not in BINDING_STATES:
137
+ raise ValueError(f"invalid binding state: {record.source_id}")
138
+ if not record.input_kinds or not record.profiles:
139
+ raise ValueError(f"capability fields missing: {record.source_id}")
140
+ if record.binding_state == "implemented" and record.runtime_provider is None:
141
+ raise ValueError(f"runtime provider missing: {record.source_id}")
142
+ if record.binding_state == "unimplemented" and record.runtime_provider is not None:
143
+ raise ValueError(f"unexpected runtime provider: {record.source_id}")
144
+ return tuple(sorted(records, key=lambda record: record.source_id))
145
+
146
+
147
+ NORMALIZED_CAPABILITIES = validate_capability_manifest()
148
+
149
+
150
+ def get_capability(source_id: str) -> CapabilityRecord:
151
+ normalized = source_id.strip().lower()
152
+ for record in validate_capability_manifest(_project_capabilities()):
153
+ if record.source_id == normalized:
154
+ return record
155
+ raise ValueError(f"source_unsupported: {source_id}")
156
+
157
+
158
+ def filter_capabilities(*, source_id: str | None = None) -> tuple[CapabilityRecord, ...]:
159
+ current = validate_capability_manifest(_project_capabilities())
160
+ if source_id is None:
161
+ return current
162
+ return tuple(record for record in current if record.source_id == source_id.strip().lower())
@@ -0,0 +1,323 @@
1
+ """Shared checks that distinguish requested source content from metadata/shells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ from dataclasses import replace
8
+
9
+ from .models import ContentItem, TranscriptResult
10
+
11
+
12
+ _ERROR_HEADING = re.compile(
13
+ r"^(?:this site can[’']t (?:be reached|provide a secure connection)|"
14
+ r"this site cannot (?:be reached|provide a secure connection)|"
15
+ r"your connection is not private|your connection is not secure|"
16
+ r"the connection was reset|the connection has timed out|"
17
+ r"secure connection failed|server not found|"
18
+ r"无法访问此网站|此网站无法提供安全连接|您的连接不是私密连接|"
19
+ r"该网页无法正常运作)[.!。!]?$",
20
+ re.IGNORECASE,
21
+ )
22
+ _ERROR_CODE = re.compile(r"^(?:net::)?(ERR_[A-Z0-9_]+)$")
23
+ _HTML_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
24
+
25
+
26
+ def _code_spans(text: str) -> list[tuple[int, int]]:
27
+ """Find literal fenced/inline code, including unclosed fenced blocks."""
28
+ spans: list[tuple[int, int]] = []
29
+ fence: str | None = None
30
+ start = offset = 0
31
+ for line in text.splitlines(keepends=True):
32
+ marker = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
33
+ if marker and fence is None:
34
+ fence = marker[1]
35
+ start = offset
36
+ elif fence is not None:
37
+ if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not line[marker.end():].strip():
38
+ spans.append((start, offset + len(line)))
39
+ fence = None
40
+ else:
41
+ spans.extend((offset + match.start(), offset + match.end()) for match in re.finditer(r"(?<!`)(`+)(?!`)[^\n]*?(?<!`)\1(?!`)", line))
42
+ offset += len(line)
43
+ if fence is not None:
44
+ spans.append((start, len(text)))
45
+ return spans
46
+
47
+
48
+ def _outside_code(text: str) -> str:
49
+ for start, end in reversed(_code_spans(text)):
50
+ text = text[:start] + "\n" * text[start:end].count("\n") + text[end:]
51
+ return text
52
+
53
+
54
+ def html_tag_residue(text: str) -> tuple[int, int]:
55
+ """Count HTML-tag lines and tags, excluding fenced and inline code."""
56
+ outside = _outside_code(text)
57
+ matches = [line for line in outside.splitlines() if _HTML_TAG.search(line)]
58
+ return len(matches), sum(len(_HTML_TAG.findall(line)) for line in matches)
59
+
60
+
61
+ def html_to_markdown(text: str) -> str:
62
+ # Protect literal code before asking the existing optional converter to
63
+ # normalize HTML embedded in an otherwise Markdown document.
64
+ protected: dict[str, str] = {}
65
+
66
+ value = text
67
+ for start, end in reversed(_code_spans(text)):
68
+ key = f"OPENMDCODE{len(protected)}PLACEHOLDER"
69
+ while key in text:
70
+ key += "X"
71
+ protected[key] = text[start:end]
72
+ value = value[:start] + key + value[end:]
73
+ try:
74
+ import markdownify # type: ignore[import-not-found]
75
+ from bs4 import BeautifulSoup # type: ignore[import-not-found]
76
+
77
+ soup = BeautifulSoup(value, "html.parser")
78
+ for hidden in soup.find_all(["script", "style", "noscript"]):
79
+ hidden.decompose()
80
+ for table in soup.find_all("table"):
81
+ # Legacy articles use nested/one-cell tables for page layout.
82
+ # Converting these to pipe tables collapses the article into one
83
+ # enormous cell. Unwrap their own structure, retaining data tables.
84
+ own = [node for node in table.find_all(["thead", "tbody", "tfoot", "tr", "td", "th"]) if node.find_parent("table") is table]
85
+ cells = [node for node in own if node.name in {"td", "th"}]
86
+ layout = (
87
+ table.get("role") == "presentation"
88
+ or (not table.find("th") and (table.find("table") is not None or len(cells) <= 1 or table.has_attr("width")))
89
+ )
90
+ if layout:
91
+ for node in own:
92
+ if node.name in {"tr", "td"}:
93
+ node.insert_before(soup.new_string("\n\n"))
94
+ node.insert_after(soup.new_string("\n\n"))
95
+ node.unwrap()
96
+ table.unwrap()
97
+
98
+ converted = markdownify.markdownify(
99
+ str(soup), heading_style="ATX", bullets="-",
100
+ escape_asterisks=False, escape_underscores=False, escape_misc=False,
101
+ )
102
+ except ModuleNotFoundError:
103
+ # The existing Discourse converter is a stdlib-only fallback. Its
104
+ # unsupported/residual markup still goes through the same partial gate.
105
+ from openmd.backends.discourse import _CookedMarkdown
106
+
107
+ # The fallback understands data tables. Remove presentation wrappers
108
+ # when no header cell identifies a real table.
109
+ if re.search(r"<table\b", value, re.I) and not re.search(r"<th\b", value, re.I):
110
+ value = re.sub(r"</?(?:table|thead|tbody|tfoot|tr|td)\b[^>]*>", "\n\n", value, flags=re.I)
111
+ parser = _CookedMarkdown("")
112
+ parser.feed(value)
113
+ parser.close()
114
+ converted = parser.markdown()
115
+ for key, literal in protected.items():
116
+ converted = converted.replace(key, literal)
117
+ return converted.strip()
118
+
119
+
120
+ def _normalize_web_body(result: TranscriptResult, item: ContentItem | None) -> TranscriptResult:
121
+ if item is None or item.platform not in {"web_article", "weixin"}:
122
+ return result
123
+ before_lines, _ = html_tag_residue(result.text)
124
+ if before_lines:
125
+ try:
126
+ converted = html_to_markdown(result.text)
127
+ except Exception:
128
+ # Keep the original body for inspection but never bless an HTML
129
+ # dump if an optional converter cannot handle it.
130
+ converted = result.text
131
+ after_lines, after_tags = html_tag_residue(converted)
132
+ metadata = {**result.metadata, "html_tag_residue_lines": after_lines, "html_tag_residue_tags": after_tags}
133
+ notes = [*result.source_notes, "Shared web content validation converted HTML to Markdown before output."]
134
+ if after_lines:
135
+ return replace(
136
+ result,
137
+ text=converted.rstrip() + "\n",
138
+ status="partial",
139
+ error_code="html_tag_residue",
140
+ error_summary=f"HTML tag residue remains after conversion ({after_lines} lines).",
141
+ source_notes=[*notes, "HTML readability gate kept status partial."],
142
+ metadata=metadata,
143
+ components=[replace(component, status="partial") if component.status == "verified" else component for component in result.components],
144
+ )
145
+ return replace(result, text=converted.rstrip() + "\n", source_notes=notes, metadata=metadata)
146
+ return replace(result, metadata={**result.metadata, "html_tag_residue_lines": 0, "html_tag_residue_tags": 0})
147
+
148
+
149
+ def browser_error_page_reason(text: str, title: str | None = None) -> str | None:
150
+ """Recognize browser diagnostics without rejecting articles quoting a code.
151
+
152
+ Error headings are standalone lines near the beginning of browser screens.
153
+ A bare error code is accepted as a failure only for a short diagnostic
154
+ screen, so an ordinary technical article mentioning ERR_* remains text.
155
+ """
156
+ plain = html.unescape(re.sub(r"<[^>]+>", "\n", text))
157
+ visible = _outside_code(plain)
158
+ headings = [line.strip().strip("#*` \t").lower().rstrip(".! …") for line in visible.splitlines() if line.strip()][:8]
159
+ page_title = str(title or "").strip().lower().rstrip(".! …")
160
+ challenge_heading = re.compile(r"^(?:just a moment|checking your browser(?: before accessing.*)?|attention required(?:[\s!|\-–—:]+cloudflare)?|verification successful|verify (?:you are|that you are) human|performing security verification)$")
161
+ # Titles and leading diagnostic headings identify the page itself. Ignore
162
+ # code examples and ordinary articles that merely discuss these phrases.
163
+ if challenge_heading.fullmatch(page_title) or any(
164
+ challenge_heading.fullmatch(heading) or re.match(r"verification successful\.?\s+waiting for .+ to respond", heading)
165
+ for heading in headings[:4]
166
+ ):
167
+ return "browser_challenge_page"
168
+ lines = [line.strip().strip("#*` ") for line in plain.splitlines() if line.strip()]
169
+ headings = [*(lines[:8]), str(title or "").strip()]
170
+ codes = [match.group(1) for line in lines[:12] if (match := _ERROR_CODE.fullmatch(line))]
171
+ screen_hint = any(
172
+ line.lower() in {"reload", "重新加载", "try running windows network diagnostics."}
173
+ for line in lines
174
+ )
175
+ if len(plain) < 3000 and any(_ERROR_HEADING.fullmatch(line) for line in headings):
176
+ if codes or screen_hint or len(lines) == 1:
177
+ return "browser_network_error_page"
178
+ if codes and len(plain) < 3000:
179
+ if len(lines) == 1 or screen_hint:
180
+ return codes[0]
181
+ return None
182
+
183
+
184
+ _WEIXIN_ERROR_LINES = re.compile(
185
+ r"(?:parameter error|参数错误|该内容已被发布者删除|此内容因违规无法查看|此内容被多人投诉.*|链接已过期|该公众号已迁移.*|"
186
+ r"该账号已.*(?:注销|冻结|封禁).*|内容不存在)"
187
+ )
188
+ _WEIXIN_CHALLENGE_LINES = re.compile(r"(?:环境异常|当前环境异常.*完成验证后即可继续访问.*)")
189
+
190
+
191
+ def weixin_error_page_reason(text: str) -> str | None:
192
+ """Recognize mp.weixin.qq.com status screens (deleted/expired/invalid/verify).
193
+
194
+ These screens are short and lead with a standalone status line; an article
195
+ that merely discusses such a phrase has body text around it.
196
+ """
197
+ lines = [line.strip().strip("#*` ").rstrip("。.!!") for line in _outside_code(text).splitlines() if line.strip()]
198
+ if not lines or len(re.sub(r"\s+", "", text)) > 600:
199
+ return None
200
+ for line in lines[:3]:
201
+ if _WEIXIN_CHALLENGE_LINES.fullmatch(line):
202
+ return "challenge"
203
+ if _WEIXIN_ERROR_LINES.fullmatch(line.lower() if line.isascii() else line):
204
+ return "error"
205
+ return None
206
+
207
+
208
+ # Single-line only: a stray "[" in prose must not swallow paragraphs up to a
209
+ # later "](...)".
210
+ _MD_IMAGE = re.compile(r"!\[[^\]\n]*\]\([^)\n]*\)")
211
+ _MD_LINK = re.compile(r"\[([^\]\n]*)\]\([^)\n]*\)")
212
+
213
+
214
+ def navigation_shell_reason(text: str) -> str | None:
215
+ """Recognize a generic web extraction that kept only site chrome.
216
+
217
+ Soft-404 and index pages extract as images, spacer GIFs and a row of
218
+ links. Both signals are required: almost no prose outside links and
219
+ headings, and a structure dominated by links/images. A short genuine post
220
+ has prose; a link-list article has many links but also has prose.
221
+ """
222
+ visible = _outside_code(text)
223
+ images = len(_MD_IMAGE.findall(visible))
224
+ without_images = _MD_IMAGE.sub("", visible)
225
+ links = len(_MD_LINK.findall(without_images))
226
+ prose_lines = []
227
+ for line in _MD_LINK.sub("", without_images).splitlines():
228
+ stripped = line.strip()
229
+ if not stripped or stripped.startswith("#") or re.fullmatch(r"[-|:\s*_>]+", stripped):
230
+ continue
231
+ prose_lines.append(stripped)
232
+ # Bare URLs (e.g. an extractor's own "原文链接: <url>" line) are not prose.
233
+ # Join with newlines so a URL cannot run into the next CJK line.
234
+ prose = re.sub(r"https?://\S+|[\s|*_>#\-::©]+", "", "\n".join(prose_lines))
235
+ if images + links >= 3 and len(prose) < 60:
236
+ return "navigation_shell"
237
+ return None
238
+
239
+
240
+ def validate_extraction_content(
241
+ result: TranscriptResult, item: ContentItem | None = None, *, target: str = "content"
242
+ ) -> TranscriptResult:
243
+ """Validate content before the executor decides whether to stop fallback.
244
+
245
+ Engine providers supply structural evidence, rather than relying on a
246
+ minimum length or filtering ordinary words from legitimate short posts.
247
+ Explicit metadata requests are separate from the default content target.
248
+ """
249
+ if result.status not in {"verified", "partial"}:
250
+ return result
251
+ result = _normalize_web_body(result, item)
252
+ reason = browser_error_page_reason(result.text, result.title)
253
+ if reason is not None:
254
+ challenge = reason == "browser_challenge_page"
255
+ return _reject_content(
256
+ result, "browser_challenge_page" if challenge else "browser_error_page",
257
+ "被站点验证页拦截,未取得目标正文。" if challenge else f"Browser returned a network/error page instead of source content ({reason}).",
258
+ failure_class="user_resolvable" if challenge else "runtime_transient",
259
+ )
260
+ if item is not None and item.platform == "weixin" and (weixin := weixin_error_page_reason(result.text)):
261
+ challenge = weixin == "challenge"
262
+ return _reject_content(
263
+ result, "browser_challenge_page" if challenge else "weixin_error_page",
264
+ "公众号要求完成环境验证,未取得正文;在浏览器里打开链接完成验证后重试。" if challenge
265
+ else "公众号返回了错误页,没有正文;链接可能失效、已删除或已过期。",
266
+ failure_class="user_resolvable" if challenge else "source_unavailable",
267
+ )
268
+ if item is not None:
269
+ target = str(item.raw_meta.get("extraction_target") or target)
270
+ if target == "metadata":
271
+ return result
272
+ metadata = result.metadata
273
+ if metadata.get("metadata_only") is True or metadata.get("content_kind") in {"metadata", "description"}:
274
+ return _reject_content(result, "content_metadata_only", "The backend produced metadata rather than the requested source content.")
275
+ if metadata.get("page_shell") is True or metadata.get("content_kind") in {"shell", "navigation"}:
276
+ return _reject_content(result, "content_shell", "The backend produced a page shell rather than the requested source content.")
277
+ if item is not None and item.platform == "web_article" and navigation_shell_reason(result.text):
278
+ return _reject_content(result, "content_navigation_shell", "页面只有导航或图片,没有正文;链接可能不存在或需要登录。")
279
+ if not result.text.strip():
280
+ # OCR document routes intentionally preserve terminal gaps for review.
281
+ if item is not None and item.platform in {"screenshot", "pdf", "office"} and metadata.get("terminal_partial") is True and result.status == "partial":
282
+ return result
283
+ return _reject_content(result, "content_empty", "The backend did not produce source content.")
284
+ engine = metadata.get("engine") or metadata.get("site_engine")
285
+ if engine is None and item is not None:
286
+ engine = item.raw_meta.get("engine") or item.raw_meta.get("site_engine")
287
+ if str(engine or "").lower() == "discourse":
288
+ post_id = metadata.get("post_id")
289
+ target_post_id = metadata.get("target_post_id")
290
+ if not post_id or not target_post_id or str(post_id) != str(target_post_id) or metadata.get("post_body_present") is not True:
291
+ return _reject_content(result, "discourse_post_body_missing", "The detected Discourse result lacks the requested post ID and body.")
292
+ # Detail may identify a /video/ URL as an image post after the original
293
+ # item was resolved. Accept that author's text only with provider evidence;
294
+ # an explicit transcript request still requires actual transcript sources.
295
+ image_post_body = (
296
+ item is not None and item.platform == "douyin"
297
+ and result.backend == "douyin.post-text"
298
+ and result.source_level == "native_text"
299
+ and metadata.get("media_kind") == "image_text"
300
+ and metadata.get("transcript_method") == "image_post_text"
301
+ and metadata.get("content_kind") == "author_text"
302
+ )
303
+ if target == "transcript" or (item is not None and item.platform == "douyin" and item.source_type == "video" and not image_post_body):
304
+ if result.source_level not in {"native_caption", "auto_caption", "platform_transcript", "asr", "media_asr", "live_capture_asr", "ocr"}:
305
+ return _reject_content(result, "transcript_not_produced", "The backend did not produce captions, ASR, or identified OCR text for the video.")
306
+ return result
307
+
308
+
309
+ def _reject_content(
310
+ result: TranscriptResult, code: str, summary: str, *, failure_class: str = "source_unavailable"
311
+ ) -> TranscriptResult:
312
+ metadata = {key: value for key, value in result.metadata.items() if key != "terminal_partial"}
313
+ return replace(
314
+ result,
315
+ text="",
316
+ status="failed",
317
+ source_level="partial_extraction",
318
+ components=[],
319
+ error_code=code,
320
+ error_summary=summary,
321
+ source_notes=[*result.source_notes, "Unusable extraction body was rejected before output."],
322
+ metadata={**metadata, "failure_class": failure_class},
323
+ )