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.
Files changed (46) hide show
  1. cnlaw/__init__.py +6 -0
  2. cnlaw/__main__.py +5 -0
  3. cnlaw/adapters/__init__.py +6 -0
  4. cnlaw/adapters/base.py +212 -0
  5. cnlaw/adapters/bse.py +232 -0
  6. cnlaw/adapters/cac.py +343 -0
  7. cnlaw/adapters/court.py +152 -0
  8. cnlaw/adapters/csrc.py +455 -0
  9. cnlaw/adapters/gov_policy.py +254 -0
  10. cnlaw/adapters/gov_rules.py +487 -0
  11. cnlaw/adapters/mee.py +177 -0
  12. cnlaw/adapters/miit.py +283 -0
  13. cnlaw/adapters/mod.py +192 -0
  14. cnlaw/adapters/moj.py +182 -0
  15. cnlaw/adapters/neeq.py +37 -0
  16. cnlaw/adapters/nfra.py +208 -0
  17. cnlaw/adapters/npc.py +859 -0
  18. cnlaw/adapters/party.py +185 -0
  19. cnlaw/adapters/samr.py +236 -0
  20. cnlaw/adapters/spp.py +292 -0
  21. cnlaw/adapters/sse.py +231 -0
  22. cnlaw/adapters/szse.py +186 -0
  23. cnlaw/adapters/tax.py +258 -0
  24. cnlaw/adapters/treaty.py +331 -0
  25. cnlaw/adapters/utils.py +36 -0
  26. cnlaw/cli.py +725 -0
  27. cnlaw/errors.py +22 -0
  28. cnlaw/federated.py +378 -0
  29. cnlaw/models.py +329 -0
  30. cnlaw/registry.py +86 -0
  31. cnlaw/skill_installer.py +250 -0
  32. cnlaw/skill_templates/cnlaw-search/SKILL.md +64 -0
  33. cnlaw/skill_templates/cnlaw-search/agents/openai.yaml +4 -0
  34. cnlaw/skill_templates/cnlaw-search/references/cli-reference.md +56 -0
  35. cnlaw/skill_templates/cnlaw-search/references/evidence-validation.md +35 -0
  36. cnlaw/skill_templates/cnlaw-search/references/output-contract.md +31 -0
  37. cnlaw/skill_templates/cnlaw-search/references/query-planning.md +50 -0
  38. cnlaw/skill_templates/cnlaw-search/references/research-workflow.md +29 -0
  39. cnlaw/skill_templates/cnlaw-search/references/source-routing.md +29 -0
  40. cnlaw/transport.py +239 -0
  41. law_cn_cli-0.2.0.dist-info/METADATA +430 -0
  42. law_cn_cli-0.2.0.dist-info/RECORD +46 -0
  43. law_cn_cli-0.2.0.dist-info/WHEEL +5 -0
  44. law_cn_cli-0.2.0.dist-info/entry_points.txt +2 -0
  45. law_cn_cli-0.2.0.dist-info/licenses/LICENSE +131 -0
  46. law_cn_cli-0.2.0.dist-info/top_level.txt +1 -0
cnlaw/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Unified CLI and adapter SDK for official Chinese legal sources."""
2
+
3
+ from .models import LawRecord, SearchQuery
4
+
5
+ __all__ = ["LawRecord", "SearchQuery"]
6
+
cnlaw/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import entrypoint
2
+
3
+
4
+ entrypoint()
5
+
@@ -0,0 +1,6 @@
1
+ """Official source adapters."""
2
+
3
+ from .base import SourceAdapter
4
+
5
+ __all__ = ["SourceAdapter"]
6
+
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
+ )