law-cn-cli 0.2.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.
- cnlaw/__init__.py +6 -0
- cnlaw/__main__.py +5 -0
- cnlaw/adapters/__init__.py +6 -0
- cnlaw/adapters/base.py +212 -0
- cnlaw/adapters/bse.py +232 -0
- cnlaw/adapters/cac.py +343 -0
- cnlaw/adapters/court.py +152 -0
- cnlaw/adapters/csrc.py +455 -0
- cnlaw/adapters/gov_policy.py +254 -0
- cnlaw/adapters/gov_rules.py +487 -0
- cnlaw/adapters/mee.py +177 -0
- cnlaw/adapters/miit.py +283 -0
- cnlaw/adapters/mod.py +192 -0
- cnlaw/adapters/moj.py +182 -0
- cnlaw/adapters/neeq.py +37 -0
- cnlaw/adapters/nfra.py +208 -0
- cnlaw/adapters/npc.py +859 -0
- cnlaw/adapters/party.py +185 -0
- cnlaw/adapters/samr.py +236 -0
- cnlaw/adapters/spp.py +292 -0
- cnlaw/adapters/sse.py +231 -0
- cnlaw/adapters/szse.py +186 -0
- cnlaw/adapters/tax.py +258 -0
- cnlaw/adapters/treaty.py +331 -0
- cnlaw/adapters/utils.py +36 -0
- cnlaw/cli.py +725 -0
- cnlaw/errors.py +22 -0
- cnlaw/federated.py +378 -0
- cnlaw/models.py +329 -0
- cnlaw/registry.py +86 -0
- cnlaw/skill_installer.py +250 -0
- cnlaw/skill_templates/cnlaw-search/SKILL.md +64 -0
- cnlaw/skill_templates/cnlaw-search/agents/openai.yaml +4 -0
- cnlaw/skill_templates/cnlaw-search/references/cli-reference.md +56 -0
- cnlaw/skill_templates/cnlaw-search/references/evidence-validation.md +35 -0
- cnlaw/skill_templates/cnlaw-search/references/output-contract.md +31 -0
- cnlaw/skill_templates/cnlaw-search/references/query-planning.md +50 -0
- cnlaw/skill_templates/cnlaw-search/references/research-workflow.md +29 -0
- cnlaw/skill_templates/cnlaw-search/references/source-routing.md +29 -0
- cnlaw/transport.py +239 -0
- law_cn_cli-0.2.0.dist-info/METADATA +430 -0
- law_cn_cli-0.2.0.dist-info/RECORD +46 -0
- law_cn_cli-0.2.0.dist-info/WHEEL +5 -0
- law_cn_cli-0.2.0.dist-info/entry_points.txt +2 -0
- law_cn_cli-0.2.0.dist-info/licenses/LICENSE +131 -0
- law_cn_cli-0.2.0.dist-info/top_level.txt +1 -0
cnlaw/__init__.py
ADDED
cnlaw/__main__.py
ADDED
cnlaw/adapters/base.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Common adapter contract and full-pagination collector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from cnlaw.errors import UnsupportedCapabilityError
|
|
13
|
+
from cnlaw.transport import DocumentFileCache, RateLimiter, ResponseCache
|
|
14
|
+
from cnlaw.models import (
|
|
15
|
+
ArticleLookup,
|
|
16
|
+
ArticleSearchResult,
|
|
17
|
+
DocumentInfo,
|
|
18
|
+
DocumentPreview,
|
|
19
|
+
LawRecord,
|
|
20
|
+
SearchManifest,
|
|
21
|
+
SearchPage,
|
|
22
|
+
SearchQuery,
|
|
23
|
+
SourceCapabilities,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class DownloadResult:
|
|
29
|
+
"""Result of an explicit file download from an official source."""
|
|
30
|
+
|
|
31
|
+
source: str
|
|
32
|
+
document_id: str
|
|
33
|
+
file_path: str
|
|
34
|
+
file_size: int
|
|
35
|
+
content_hash: str
|
|
36
|
+
format: str
|
|
37
|
+
official_url: str
|
|
38
|
+
downloaded_at: str = field(
|
|
39
|
+
default_factory=lambda: datetime.now(UTC).isoformat()
|
|
40
|
+
)
|
|
41
|
+
cache_hit: bool = False
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
return asdict(self)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class BatchDownloadFailure:
|
|
49
|
+
document_id: str
|
|
50
|
+
error: str
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return asdict(self)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class BatchDownloadResult:
|
|
58
|
+
source: str
|
|
59
|
+
requested: int
|
|
60
|
+
downloaded: tuple[DownloadResult, ...]
|
|
61
|
+
failures: tuple[BatchDownloadFailure, ...]
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
return {
|
|
65
|
+
"source": self.source,
|
|
66
|
+
"requested": self.requested,
|
|
67
|
+
"downloaded": [result.to_dict() for result in self.downloaded],
|
|
68
|
+
"failures": [failure.to_dict() for failure in self.failures],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SourceAdapter(ABC):
|
|
73
|
+
code: str
|
|
74
|
+
name: str
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
client: httpx.Client | None = None,
|
|
79
|
+
*,
|
|
80
|
+
cache: ResponseCache | None = None,
|
|
81
|
+
file_cache: DocumentFileCache | None = None,
|
|
82
|
+
rate_limiter: RateLimiter | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.client = client or httpx.Client(
|
|
85
|
+
follow_redirects=True,
|
|
86
|
+
timeout=30,
|
|
87
|
+
headers={
|
|
88
|
+
"User-Agent": (
|
|
89
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
90
|
+
"AppleWebKit/537.36 Chrome/125 Safari/537.36"
|
|
91
|
+
)
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
self._cache = cache
|
|
95
|
+
self._file_cache = file_cache
|
|
96
|
+
self._rate_limiter = rate_limiter
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def capabilities(self) -> SourceCapabilities:
|
|
100
|
+
"""Describe which unified filters this source can honor."""
|
|
101
|
+
|
|
102
|
+
def _cached_get(self, url: str, *, headers: dict | None = None, params: dict | None = None, ttl: float = 3600.0):
|
|
103
|
+
"""GET with optional caching and rate limiting."""
|
|
104
|
+
cache_key_body = str(sorted((params or {}).items())).encode() if params else None
|
|
105
|
+
if self._cache is not None:
|
|
106
|
+
cached = self._cache.get("GET", url, cache_key_body)
|
|
107
|
+
if cached is not None:
|
|
108
|
+
return cached
|
|
109
|
+
if self._rate_limiter is not None:
|
|
110
|
+
self._rate_limiter.acquire()
|
|
111
|
+
response = self.client.get(url, headers=headers or {}, params=params or {})
|
|
112
|
+
if self._rate_limiter is not None and response.status_code == 429:
|
|
113
|
+
self._rate_limiter.record_429()
|
|
114
|
+
elif self._rate_limiter is not None:
|
|
115
|
+
self._rate_limiter.record_success()
|
|
116
|
+
if self._cache is not None and response.is_success:
|
|
117
|
+
self._cache.set("GET", url, response, ttl=ttl, body=cache_key_body)
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def search_pages(self, query: SearchQuery) -> Iterator[SearchPage]:
|
|
122
|
+
"""Yield every result page reported by the official source."""
|
|
123
|
+
|
|
124
|
+
def info(self, document_id: str) -> DocumentInfo:
|
|
125
|
+
"""Fetch one official document detail page by source-native ID."""
|
|
126
|
+
raise UnsupportedCapabilityError(
|
|
127
|
+
f"{self.code} does not support document info"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def article(
|
|
131
|
+
self,
|
|
132
|
+
document_id: str,
|
|
133
|
+
*,
|
|
134
|
+
article_number: str | None = None,
|
|
135
|
+
grep: str | None = None,
|
|
136
|
+
refresh: bool = False,
|
|
137
|
+
) -> ArticleLookup:
|
|
138
|
+
"""Extract article text from one official source document."""
|
|
139
|
+
raise UnsupportedCapabilityError(
|
|
140
|
+
f"{self.code} does not support article extraction"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def preview(
|
|
144
|
+
self, document_id: str, *, refresh: bool = False
|
|
145
|
+
) -> DocumentPreview:
|
|
146
|
+
"""Show regulation structure: TOC, article count, sample article numbers."""
|
|
147
|
+
raise UnsupportedCapabilityError(
|
|
148
|
+
f"{self.code} does not support document preview"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def article_search(
|
|
152
|
+
self,
|
|
153
|
+
keyword: str,
|
|
154
|
+
*,
|
|
155
|
+
max_laws: int | None = None,
|
|
156
|
+
statuses: tuple[str, ...] = (),
|
|
157
|
+
offset: int = 0,
|
|
158
|
+
context: int = 0,
|
|
159
|
+
refresh: bool = False,
|
|
160
|
+
) -> ArticleSearchResult:
|
|
161
|
+
"""Search across multiple laws for articles containing a keyword."""
|
|
162
|
+
raise UnsupportedCapabilityError(
|
|
163
|
+
f"{self.code} does not support cross-law article search"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def download(
|
|
167
|
+
self,
|
|
168
|
+
document_id: str,
|
|
169
|
+
*,
|
|
170
|
+
format: str = "docx",
|
|
171
|
+
output: Path | None = None,
|
|
172
|
+
refresh: bool = False,
|
|
173
|
+
) -> "DownloadResult":
|
|
174
|
+
"""Download an official document file to disk."""
|
|
175
|
+
raise UnsupportedCapabilityError(
|
|
176
|
+
f"{self.code} does not support file download"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def search(self, query: SearchQuery) -> tuple[list[LawRecord], SearchManifest]:
|
|
180
|
+
records: list[LawRecord] = []
|
|
181
|
+
pages_fetched = 0
|
|
182
|
+
total_reported: int | None = None
|
|
183
|
+
truncated = False
|
|
184
|
+
|
|
185
|
+
for page in self.search_pages(query):
|
|
186
|
+
pages_fetched += 1
|
|
187
|
+
if page.total is not None:
|
|
188
|
+
total_reported = page.total
|
|
189
|
+
for record in page.records:
|
|
190
|
+
if query.limit is not None and len(records) >= query.limit:
|
|
191
|
+
truncated = True
|
|
192
|
+
break
|
|
193
|
+
records.append(record)
|
|
194
|
+
if truncated:
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
query.limit is not None
|
|
199
|
+
and total_reported is not None
|
|
200
|
+
and total_reported > len(records)
|
|
201
|
+
):
|
|
202
|
+
truncated = True
|
|
203
|
+
|
|
204
|
+
return records, SearchManifest(
|
|
205
|
+
source=self.code,
|
|
206
|
+
keyword=query.keyword,
|
|
207
|
+
pages_fetched=pages_fetched,
|
|
208
|
+
records_written=len(records),
|
|
209
|
+
total_reported=total_reported,
|
|
210
|
+
explicit_limit=query.limit,
|
|
211
|
+
truncated=truncated,
|
|
212
|
+
)
|
cnlaw/adapters/bse.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""北京证券交易所业务规则 JSONP search adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from urllib.parse import urlparse, urlencode
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from cnlaw.errors import SourceParseError
|
|
16
|
+
from cnlaw.models import (
|
|
17
|
+
CapabilityLevel,
|
|
18
|
+
DocumentInfo,
|
|
19
|
+
LawRecord,
|
|
20
|
+
SearchParameter,
|
|
21
|
+
SearchPage,
|
|
22
|
+
SearchQuery,
|
|
23
|
+
SourceCapabilities,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .base import SourceAdapter
|
|
27
|
+
from .utils import canonical_https, clean_html
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BSEAdapter(SourceAdapter):
|
|
31
|
+
code = "bse"
|
|
32
|
+
name = "北京证券交易所规则"
|
|
33
|
+
search_url = "https://www.bse.cn/infoEsController/searchDetails.do"
|
|
34
|
+
base_url = "https://www.bse.cn"
|
|
35
|
+
search_referer = "https://www.bse.cn/node/latestRule.html"
|
|
36
|
+
site_id = "6"
|
|
37
|
+
issuing_authority = "北京证券交易所"
|
|
38
|
+
page_size = 20
|
|
39
|
+
business_rule_node_ids = (
|
|
40
|
+
"1302",
|
|
41
|
+
"1303",
|
|
42
|
+
"1304",
|
|
43
|
+
"3130",
|
|
44
|
+
"3131",
|
|
45
|
+
"3132",
|
|
46
|
+
"1306",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(self, client: httpx.Client | None = None) -> None:
|
|
50
|
+
# The CDN currently sends one same-URL challenge redirect and a short-
|
|
51
|
+
# lived C3VK cookie. It remains only in this in-memory client.
|
|
52
|
+
self.client = client or httpx.Client(
|
|
53
|
+
follow_redirects=False,
|
|
54
|
+
timeout=30,
|
|
55
|
+
headers={
|
|
56
|
+
"User-Agent": (
|
|
57
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
58
|
+
"AppleWebKit/537.36 Chrome/125 Safari/537.36"
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def capabilities(self) -> SourceCapabilities:
|
|
64
|
+
return SourceCapabilities(
|
|
65
|
+
source=self.code,
|
|
66
|
+
transport="frontend_jsonp_api",
|
|
67
|
+
filters={
|
|
68
|
+
"scope:all": CapabilityLevel.NATIVE,
|
|
69
|
+
"publish_date": CapabilityLevel.NATIVE,
|
|
70
|
+
"sort:newest": CapabilityLevel.NATIVE,
|
|
71
|
+
},
|
|
72
|
+
source_parameters=(
|
|
73
|
+
SearchParameter(
|
|
74
|
+
"rule_channel", ("nodeIds[]",), "规则栏目叶节点 ID;默认全部业务规则栏目",
|
|
75
|
+
repeatable=True,
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _body(self, query: SearchQuery, page: int) -> str:
|
|
81
|
+
pairs = [
|
|
82
|
+
("page", str(page)),
|
|
83
|
+
("pageSize", str(self.page_size)),
|
|
84
|
+
("keywords", query.keyword),
|
|
85
|
+
("startTime", query.publish_from or ""),
|
|
86
|
+
("endTime", query.publish_to or ""),
|
|
87
|
+
]
|
|
88
|
+
node_ids = query.source_param_values("rule_channel") or self.business_rule_node_ids
|
|
89
|
+
pairs.extend(("nodeIds[]", node_id) for node_id in node_ids)
|
|
90
|
+
pairs.extend(
|
|
91
|
+
[
|
|
92
|
+
("siteId", self.site_id),
|
|
93
|
+
("relevancy", "1" if query.sort == "newest" else "0"),
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
return urlencode(pairs)
|
|
97
|
+
|
|
98
|
+
def _post(self, query: SearchQuery, page: int) -> httpx.Response:
|
|
99
|
+
response = self.client.post(
|
|
100
|
+
self.search_url,
|
|
101
|
+
params={"callback": "cnlaw"},
|
|
102
|
+
content=self._body(query, page),
|
|
103
|
+
headers={
|
|
104
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
105
|
+
"Referer": self.search_referer,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
if response.status_code in {301, 302, 307, 308} and response.headers.get(
|
|
109
|
+
"set-cookie"
|
|
110
|
+
):
|
|
111
|
+
response = self.client.post(
|
|
112
|
+
self.search_url,
|
|
113
|
+
params={"callback": "cnlaw"},
|
|
114
|
+
content=self._body(query, page),
|
|
115
|
+
headers={
|
|
116
|
+
"Content-Type": (
|
|
117
|
+
"application/x-www-form-urlencoded; charset=UTF-8"
|
|
118
|
+
),
|
|
119
|
+
"Referer": self.search_referer,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _parse_jsonp(text: str) -> dict:
|
|
126
|
+
match = re.fullmatch(r"\s*[^(]+\((.*)\)\s*;?\s*", text, re.DOTALL)
|
|
127
|
+
if not match:
|
|
128
|
+
raise SourceParseError("bse search response is not JSONP")
|
|
129
|
+
try:
|
|
130
|
+
outer = json.loads(match.group(1))
|
|
131
|
+
except json.JSONDecodeError as exc:
|
|
132
|
+
raise SourceParseError("bse search JSONP payload is invalid") from exc
|
|
133
|
+
if (
|
|
134
|
+
not isinstance(outer, list)
|
|
135
|
+
or not outer
|
|
136
|
+
or outer[0].get("result") is not True
|
|
137
|
+
or not isinstance(outer[0].get("data"), dict)
|
|
138
|
+
):
|
|
139
|
+
raise SourceParseError("bse search response contract changed")
|
|
140
|
+
return outer[0]["data"]
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _document_number(text: str) -> str | None:
|
|
144
|
+
match = re.search(r"北证[^\s〔]{0,8}〔\d{4}〕\d+\s*号", text)
|
|
145
|
+
return match.group(0).replace(" ", "") if match else None
|
|
146
|
+
|
|
147
|
+
def _official_url(self, value: str) -> str:
|
|
148
|
+
if value.startswith("//"):
|
|
149
|
+
return f"https:{value}"
|
|
150
|
+
if value.startswith("/"):
|
|
151
|
+
return f"{self.base_url}{value}"
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
def search_pages(self, query: SearchQuery) -> Iterator[SearchPage]:
|
|
155
|
+
page = 0
|
|
156
|
+
page_count = 1
|
|
157
|
+
rank = 0
|
|
158
|
+
while page < page_count:
|
|
159
|
+
response = self._post(query, page)
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
data = self._parse_jsonp(response.text)
|
|
162
|
+
items = data.get("content")
|
|
163
|
+
if not isinstance(items, list):
|
|
164
|
+
raise SourceParseError("bse search content is not a list")
|
|
165
|
+
total = int(data.get("totalElements") or 0)
|
|
166
|
+
size = int(data.get("size") or self.page_size)
|
|
167
|
+
page_count = max(1, math.ceil(total / size))
|
|
168
|
+
records = []
|
|
169
|
+
for item in items:
|
|
170
|
+
rank += 1
|
|
171
|
+
summary = clean_html(item.get("description"))
|
|
172
|
+
relative_url = item.get("linkUrl") or item.get("url") or ""
|
|
173
|
+
records.append(
|
|
174
|
+
LawRecord(
|
|
175
|
+
source=self.code,
|
|
176
|
+
source_name=self.name,
|
|
177
|
+
source_document_id=str(item.get("id") or "") or None,
|
|
178
|
+
title=clean_html(item.get("title")),
|
|
179
|
+
official_url=self._official_url(relative_url),
|
|
180
|
+
document_type="交易所规则",
|
|
181
|
+
issuing_authority=self.issuing_authority,
|
|
182
|
+
document_number=self._document_number(summary),
|
|
183
|
+
publish_date=item.get("publishDate") or None,
|
|
184
|
+
summary=summary or None,
|
|
185
|
+
source_rank=rank,
|
|
186
|
+
raw_metadata=item,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
yield SearchPage(
|
|
190
|
+
source=self.code,
|
|
191
|
+
page=page,
|
|
192
|
+
total=total,
|
|
193
|
+
records=tuple(records),
|
|
194
|
+
)
|
|
195
|
+
page += 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_INFO_ALLOWED_DOMAINS = frozenset({"www.bse.cn", "bse.cn"})
|
|
199
|
+
|
|
200
|
+
def info(self, document_id: str) -> DocumentInfo:
|
|
201
|
+
"""Fetch one document detail page."""
|
|
202
|
+
document_id = document_id.strip()
|
|
203
|
+
if not document_id:
|
|
204
|
+
raise ValueError("document id must not be empty")
|
|
205
|
+
url = document_id if document_id.startswith("http") else canonical_https(document_id)
|
|
206
|
+
parsed = urlparse(url)
|
|
207
|
+
if parsed.hostname not in self._INFO_ALLOWED_DOMAINS:
|
|
208
|
+
raise ValueError(f"bse info URL must be on an official domain, got: {parsed.hostname}")
|
|
209
|
+
response = self.client.get(url)
|
|
210
|
+
response.raise_for_status()
|
|
211
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
212
|
+
title_node = soup.find("h1") or soup.find("title")
|
|
213
|
+
title = clean_html(str(title_node)) if title_node else url
|
|
214
|
+
body_container = (
|
|
215
|
+
soup.select_one(".in_main")
|
|
216
|
+
or soup.select_one(".article-content")
|
|
217
|
+
or soup.select_one(".detail-content")
|
|
218
|
+
or soup.select_one(".news-content")
|
|
219
|
+
or soup.select_one(".content")
|
|
220
|
+
)
|
|
221
|
+
body = clean_html(str(body_container)) if body_container else None
|
|
222
|
+
return DocumentInfo(
|
|
223
|
+
source=self.code,
|
|
224
|
+
source_name=self.name,
|
|
225
|
+
source_document_id=document_id,
|
|
226
|
+
title=title,
|
|
227
|
+
official_url=url,
|
|
228
|
+
body=body,
|
|
229
|
+
body_availability="full" if body else "unavailable",
|
|
230
|
+
body_note=None if body else "详情页未找到正文容器。",
|
|
231
|
+
raw_metadata={"fetched_url": url},
|
|
232
|
+
)
|