twlaw 0.1.0__tar.gz
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.
- twlaw-0.1.0/LICENSE +35 -0
- twlaw-0.1.0/PKG-INFO +282 -0
- twlaw-0.1.0/README.md +253 -0
- twlaw-0.1.0/pyproject.toml +54 -0
- twlaw-0.1.0/pyproject.toml.orig +44 -0
- twlaw-0.1.0/src/twlaw/__init__.py +15 -0
- twlaw-0.1.0/src/twlaw/db.py +319 -0
- twlaw-0.1.0/src/twlaw/fetch.py +40 -0
- twlaw-0.1.0/src/twlaw/parse.py +124 -0
twlaw-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yihsuan Chen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This license covers the twlaw source code only.
|
|
26
|
+
|
|
27
|
+
twlaw does not contain or distribute any legal data. Laws and regulations are
|
|
28
|
+
downloaded at runtime by LawDB.refresh() from the Ministry of Justice's
|
|
29
|
+
"Laws & Regulations Database of the Republic of China (Taiwan)"
|
|
30
|
+
(全國法規資料庫), and are licensed separately by the MOJ under the Open
|
|
31
|
+
Government Data License, version 1.0 (政府資料開放授權條款-第1版).
|
|
32
|
+
|
|
33
|
+
Users of that data must comply with its terms, including the requirement to
|
|
34
|
+
attribute the source. See https://data.gov.tw/license and
|
|
35
|
+
https://law.moj.gov.tw/ for details.
|
twlaw-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twlaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Queryable local access to Taiwan's national laws and regulations database
|
|
5
|
+
Keywords: taiwan,law,legal,regulations,sqlite,全國法規資料庫
|
|
6
|
+
Author: Yihsuan Chen
|
|
7
|
+
Author-email: Yihsuan Chen <yhc0712.tw@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Legal Industry
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
20
|
+
Classifier: Natural Language :: Chinese (Traditional)
|
|
21
|
+
Classifier: Natural Language :: English
|
|
22
|
+
Requires-Dist: requests>=2.34.2
|
|
23
|
+
Requires-Dist: truststore>=0.10.4
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Project-URL: Homepage, https://github.com/yhc0712/twlaw
|
|
26
|
+
Project-URL: Repository, https://github.com/yhc0712/twlaw
|
|
27
|
+
Project-URL: Issues, https://github.com/yhc0712/twlaw/issues
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# twlaw
|
|
31
|
+
|
|
32
|
+
Queryable local access to Taiwan's national laws and regulations database
|
|
33
|
+
(全國法規資料庫).
|
|
34
|
+
|
|
35
|
+
繁體中文說明:[README.zh-TW.md](README.zh-TW.md)
|
|
36
|
+
|
|
37
|
+
## Why
|
|
38
|
+
|
|
39
|
+
The MOJ Open API only serves whole-database zip dumps. There is no search, no
|
|
40
|
+
per-record lookup, and chapter headings are flattened into the article list with
|
|
41
|
+
indentation as the only clue to structure. `twlaw` downloads those dumps,
|
|
42
|
+
reconstructs the hierarchy for every article, and stores the result in a local
|
|
43
|
+
SQLite database you can query.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv add twlaw # or: pip install twlaw
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Getting started
|
|
52
|
+
|
|
53
|
+
Building the local database is a separate, explicit step. Do it once:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from twlaw import LawDB
|
|
57
|
+
|
|
58
|
+
db = LawDB() # opens ~/.twlaw/law.db (created if missing)
|
|
59
|
+
db.refresh() # downloads all 4 datasets — ~2 min, ~520 MB on disk
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
After that, every query is local and fast (single-digit milliseconds). Nothing
|
|
63
|
+
touches the network again until you call `refresh()` yourself.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from twlaw import LawDB
|
|
67
|
+
|
|
68
|
+
db = LawDB() # reopens the existing database
|
|
69
|
+
if db.is_empty: # guard for first run
|
|
70
|
+
db.refresh()
|
|
71
|
+
|
|
72
|
+
for hit in db.search("扣繳義務人", limit=5):
|
|
73
|
+
print(hit["law_name"], hit["article_no"])
|
|
74
|
+
print(" ", hit["chapter_path"])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
所得稅法 第 94 條
|
|
79
|
+
第 四 章 稽徵程序 / 第 四 節 扣繳
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
### `LawDB(path=None)`
|
|
85
|
+
|
|
86
|
+
Opens (and creates if needed) the SQLite store. Defaults to `~/.twlaw/law.db`;
|
|
87
|
+
pass a path to keep it elsewhere. Usable as a context manager.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
with LawDB("./law.db") as db:
|
|
91
|
+
...
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `db.refresh(categories=("law", "order"), langs=("zh", "en"))`
|
|
95
|
+
|
|
96
|
+
Re-downloads datasets and replaces their rows. Takes ~2 minutes for everything.
|
|
97
|
+
Returns how many laws were stored per dataset. Narrow the scope if you only
|
|
98
|
+
need part of it:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
db.refresh(categories=("law",), langs=("zh",)) # Chinese statutes only, ~20 s
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Safe to re-run — rows are replaced, not duplicated.
|
|
105
|
+
|
|
106
|
+
### `db.search(query, lang="zh", category=None, limit=50, include_repealed=False)`
|
|
107
|
+
|
|
108
|
+
Full-text search over article text. Returns a list of dicts, best match first:
|
|
109
|
+
|
|
110
|
+
| key | meaning |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `law_id` | law code, e.g. `G0340003` |
|
|
113
|
+
| `law_name` | e.g. `所得稅法` |
|
|
114
|
+
| `category` | `law` or `order` |
|
|
115
|
+
| `article_no` | e.g. `第 94 條` |
|
|
116
|
+
| `article_key` | citable number, e.g. `94` or `4-1` |
|
|
117
|
+
| `content` | the article text |
|
|
118
|
+
| `chapter_path` | reconstructed hierarchy |
|
|
119
|
+
| `seq` | article position within the law |
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
db.search("營業稅", category="law") # statutes only, skip 命令
|
|
123
|
+
db.search("income tax", lang="en") # English corpus
|
|
124
|
+
db.search("設籍", include_repealed=True) # include (刪除) articles
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `db.get_law(law, lang="zh")`
|
|
128
|
+
|
|
129
|
+
One law with all its articles, or `None` if not found. Takes a law **name** or
|
|
130
|
+
a MOJ law code — use whichever you have. Returns the metadata fields plus an
|
|
131
|
+
`articles` list of `{seq, article_no, content, chapter_path}`.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
law = db.get_law("所得稅法") # by name
|
|
135
|
+
law = db.get_law("G0340003") # same law, by code
|
|
136
|
+
|
|
137
|
+
law["name"] # 所得稅法
|
|
138
|
+
law["id"] # G0340003
|
|
139
|
+
law["modified_date"] # 20260911
|
|
140
|
+
len(law["articles"]) # 198
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Names match exactly, not by prefix — `"所得稅法"` gives you 所得稅法, never
|
|
144
|
+
所得稅法施行細則. Use `list_laws(name_like=...)` or `search()` when you don't
|
|
145
|
+
know the exact name.
|
|
146
|
+
|
|
147
|
+
### `db.get_article(law, article, lang="zh")`
|
|
148
|
+
|
|
149
|
+
One article by the number you'd cite it by, or `None` if there is no such
|
|
150
|
+
article. `4` is 第 4 條 and `"4-1"` is 第 4 條之一 — you never deal with list
|
|
151
|
+
positions or MOJ's label strings.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
db.get_article("所得稅法", 1) # 第 1 條
|
|
155
|
+
db.get_article("所得稅法", "4-1") # 第 4 條之一 — a distinct article from 第 4 條
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
{'seq': 8, 'article_no': '第 4-1 條', 'article_key': '4-1',
|
|
160
|
+
'content': '自中華民國七十九年一月一日起,證券交易所得停止課徵所得稅…',
|
|
161
|
+
'chapter_path': '第 一 章 總則 / 第 一 節 一般規定',
|
|
162
|
+
'law_id': 'G0340003', 'law_name': '所得稅法'}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Every article everywhere carries this `article_key`, so a `search()` hit can be
|
|
166
|
+
re-fetched or cited directly:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
hit = db.search("扣繳義務人")[0]
|
|
170
|
+
db.get_article(hit["law_name"], hit["article_key"])
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `db.list_laws(category=None, lang="zh", name_like=None)`
|
|
174
|
+
|
|
175
|
+
Law metadata without article bodies — for browsing or building a picker.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
db.list_laws(name_like="所得稅")
|
|
179
|
+
db.list_laws(category="law")
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### `db.update_date()` / `db.is_empty`
|
|
183
|
+
|
|
184
|
+
MOJ's own publication date for the stored data, and whether anything is stored
|
|
185
|
+
yet. Use `update_date()` to decide if a `refresh()` is worthwhile.
|
|
186
|
+
|
|
187
|
+
## Examples
|
|
188
|
+
|
|
189
|
+
Look up a single article:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
print(db.get_article("所得稅法", "4-1")["content"])
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Read one law article by article:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
law = db.get_law("所得稅法")
|
|
199
|
+
|
|
200
|
+
for a in law["articles"]:
|
|
201
|
+
print(a["article_no"], a["chapter_path"])
|
|
202
|
+
print(a["content"])
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Find which laws mention a term:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
for hit in db.search("營業稅", limit=10):
|
|
209
|
+
print(hit["law_name"], hit["article_no"])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
List every tax law, using MOJ's own classification:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
for l in db.list_laws():
|
|
216
|
+
if "賦稅" in l["moj_category"]:
|
|
217
|
+
print(l["id"], l["name"])
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Chunking for RAG
|
|
221
|
+
|
|
222
|
+
Each article already carries its chapter path, so a chunk is interpretable on
|
|
223
|
+
its own without extra work:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
law = db.get_law("所得稅法")
|
|
227
|
+
|
|
228
|
+
for a in law["articles"]:
|
|
229
|
+
chunk = f"{law['name']} {a['article_no']}\n{a['chapter_path']}\n{a['content']}"
|
|
230
|
+
print(chunk)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Coverage
|
|
234
|
+
|
|
235
|
+
| dataset | records |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| laws, Chinese | 1,347 |
|
|
238
|
+
| orders, Chinese | 10,451 |
|
|
239
|
+
| laws, English | 972 |
|
|
240
|
+
| orders, English | 2,206 |
|
|
241
|
+
|
|
242
|
+
English records are a separate, smaller corpus — not a translation of every
|
|
243
|
+
Chinese record. A law present in both shares the same `law_id`, so
|
|
244
|
+
`get_law(id, lang="en")` gives you the English text of the same statute.
|
|
245
|
+
|
|
246
|
+
## Notes
|
|
247
|
+
|
|
248
|
+
- **Concurrency.** WAL mode is on, so many processes can read at once. Only
|
|
249
|
+
`refresh()` writes.
|
|
250
|
+
- **Search.** FTS5 with the `trigram` tokenizer, which is what makes Chinese
|
|
251
|
+
substring search work: the default `unicode61` treats an unbroken run of CJK
|
|
252
|
+
as one token, which would find 50 articles for 所得稅 instead of ~1,400.
|
|
253
|
+
Queries under 3 characters fall back to `LIKE`.
|
|
254
|
+
- **Storage.** Plain SQLite tables (`laws`, `articles`), so the file is readable
|
|
255
|
+
from any language, not only Python.
|
|
256
|
+
|
|
257
|
+
## Data source and licensing
|
|
258
|
+
|
|
259
|
+
`twlaw` itself is MIT licensed, and the package ships **no legal data**. Laws
|
|
260
|
+
and regulations are downloaded at runtime by `refresh()` from the
|
|
261
|
+
[MOJ Open API](https://law.moj.gov.tw/api/swagger/index.html).
|
|
262
|
+
|
|
263
|
+
That data is published by the Ministry of Justice under the
|
|
264
|
+
[Open Government Data License v1.0](https://data.gov.tw/license)
|
|
265
|
+
(政府資料開放授權條款-第1版), which permits free reuse, modification and
|
|
266
|
+
redistribution — **provided you attribute the source**. If you ship a product
|
|
267
|
+
built on this data, credit it, for example:
|
|
268
|
+
|
|
269
|
+
> Data source: Laws & Regulations Database of the Republic of China (Taiwan),
|
|
270
|
+
> Ministry of Justice — https://law.moj.gov.tw/
|
|
271
|
+
|
|
272
|
+
Two things worth knowing if you build on this:
|
|
273
|
+
|
|
274
|
+
- **The MOJ database is authoritative; this is a convenience mirror.** `twlaw`
|
|
275
|
+
reconstructs chapter hierarchy and splits articles, which is ordinary
|
|
276
|
+
permitted adaptation — but the result is a *parsed* view. Cite the official
|
|
277
|
+
text for anything that matters.
|
|
278
|
+
- **Don't misrepresent the content.** The license asks that the data not be
|
|
279
|
+
altered in ways that make the displayed information contradict the original.
|
|
280
|
+
|
|
281
|
+
This section is a summary, not legal advice. Read the license if the
|
|
282
|
+
distinction matters to you.
|
twlaw-0.1.0/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# twlaw
|
|
2
|
+
|
|
3
|
+
Queryable local access to Taiwan's national laws and regulations database
|
|
4
|
+
(全國法規資料庫).
|
|
5
|
+
|
|
6
|
+
繁體中文說明:[README.zh-TW.md](README.zh-TW.md)
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
The MOJ Open API only serves whole-database zip dumps. There is no search, no
|
|
11
|
+
per-record lookup, and chapter headings are flattened into the article list with
|
|
12
|
+
indentation as the only clue to structure. `twlaw` downloads those dumps,
|
|
13
|
+
reconstructs the hierarchy for every article, and stores the result in a local
|
|
14
|
+
SQLite database you can query.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add twlaw # or: pip install twlaw
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Getting started
|
|
23
|
+
|
|
24
|
+
Building the local database is a separate, explicit step. Do it once:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from twlaw import LawDB
|
|
28
|
+
|
|
29
|
+
db = LawDB() # opens ~/.twlaw/law.db (created if missing)
|
|
30
|
+
db.refresh() # downloads all 4 datasets — ~2 min, ~520 MB on disk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After that, every query is local and fast (single-digit milliseconds). Nothing
|
|
34
|
+
touches the network again until you call `refresh()` yourself.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from twlaw import LawDB
|
|
38
|
+
|
|
39
|
+
db = LawDB() # reopens the existing database
|
|
40
|
+
if db.is_empty: # guard for first run
|
|
41
|
+
db.refresh()
|
|
42
|
+
|
|
43
|
+
for hit in db.search("扣繳義務人", limit=5):
|
|
44
|
+
print(hit["law_name"], hit["article_no"])
|
|
45
|
+
print(" ", hit["chapter_path"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
所得稅法 第 94 條
|
|
50
|
+
第 四 章 稽徵程序 / 第 四 節 扣繳
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
### `LawDB(path=None)`
|
|
56
|
+
|
|
57
|
+
Opens (and creates if needed) the SQLite store. Defaults to `~/.twlaw/law.db`;
|
|
58
|
+
pass a path to keep it elsewhere. Usable as a context manager.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
with LawDB("./law.db") as db:
|
|
62
|
+
...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `db.refresh(categories=("law", "order"), langs=("zh", "en"))`
|
|
66
|
+
|
|
67
|
+
Re-downloads datasets and replaces their rows. Takes ~2 minutes for everything.
|
|
68
|
+
Returns how many laws were stored per dataset. Narrow the scope if you only
|
|
69
|
+
need part of it:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
db.refresh(categories=("law",), langs=("zh",)) # Chinese statutes only, ~20 s
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Safe to re-run — rows are replaced, not duplicated.
|
|
76
|
+
|
|
77
|
+
### `db.search(query, lang="zh", category=None, limit=50, include_repealed=False)`
|
|
78
|
+
|
|
79
|
+
Full-text search over article text. Returns a list of dicts, best match first:
|
|
80
|
+
|
|
81
|
+
| key | meaning |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `law_id` | law code, e.g. `G0340003` |
|
|
84
|
+
| `law_name` | e.g. `所得稅法` |
|
|
85
|
+
| `category` | `law` or `order` |
|
|
86
|
+
| `article_no` | e.g. `第 94 條` |
|
|
87
|
+
| `article_key` | citable number, e.g. `94` or `4-1` |
|
|
88
|
+
| `content` | the article text |
|
|
89
|
+
| `chapter_path` | reconstructed hierarchy |
|
|
90
|
+
| `seq` | article position within the law |
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
db.search("營業稅", category="law") # statutes only, skip 命令
|
|
94
|
+
db.search("income tax", lang="en") # English corpus
|
|
95
|
+
db.search("設籍", include_repealed=True) # include (刪除) articles
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `db.get_law(law, lang="zh")`
|
|
99
|
+
|
|
100
|
+
One law with all its articles, or `None` if not found. Takes a law **name** or
|
|
101
|
+
a MOJ law code — use whichever you have. Returns the metadata fields plus an
|
|
102
|
+
`articles` list of `{seq, article_no, content, chapter_path}`.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
law = db.get_law("所得稅法") # by name
|
|
106
|
+
law = db.get_law("G0340003") # same law, by code
|
|
107
|
+
|
|
108
|
+
law["name"] # 所得稅法
|
|
109
|
+
law["id"] # G0340003
|
|
110
|
+
law["modified_date"] # 20260911
|
|
111
|
+
len(law["articles"]) # 198
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Names match exactly, not by prefix — `"所得稅法"` gives you 所得稅法, never
|
|
115
|
+
所得稅法施行細則. Use `list_laws(name_like=...)` or `search()` when you don't
|
|
116
|
+
know the exact name.
|
|
117
|
+
|
|
118
|
+
### `db.get_article(law, article, lang="zh")`
|
|
119
|
+
|
|
120
|
+
One article by the number you'd cite it by, or `None` if there is no such
|
|
121
|
+
article. `4` is 第 4 條 and `"4-1"` is 第 4 條之一 — you never deal with list
|
|
122
|
+
positions or MOJ's label strings.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
db.get_article("所得稅法", 1) # 第 1 條
|
|
126
|
+
db.get_article("所得稅法", "4-1") # 第 4 條之一 — a distinct article from 第 4 條
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
{'seq': 8, 'article_no': '第 4-1 條', 'article_key': '4-1',
|
|
131
|
+
'content': '自中華民國七十九年一月一日起,證券交易所得停止課徵所得稅…',
|
|
132
|
+
'chapter_path': '第 一 章 總則 / 第 一 節 一般規定',
|
|
133
|
+
'law_id': 'G0340003', 'law_name': '所得稅法'}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Every article everywhere carries this `article_key`, so a `search()` hit can be
|
|
137
|
+
re-fetched or cited directly:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
hit = db.search("扣繳義務人")[0]
|
|
141
|
+
db.get_article(hit["law_name"], hit["article_key"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `db.list_laws(category=None, lang="zh", name_like=None)`
|
|
145
|
+
|
|
146
|
+
Law metadata without article bodies — for browsing or building a picker.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
db.list_laws(name_like="所得稅")
|
|
150
|
+
db.list_laws(category="law")
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### `db.update_date()` / `db.is_empty`
|
|
154
|
+
|
|
155
|
+
MOJ's own publication date for the stored data, and whether anything is stored
|
|
156
|
+
yet. Use `update_date()` to decide if a `refresh()` is worthwhile.
|
|
157
|
+
|
|
158
|
+
## Examples
|
|
159
|
+
|
|
160
|
+
Look up a single article:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
print(db.get_article("所得稅法", "4-1")["content"])
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Read one law article by article:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
law = db.get_law("所得稅法")
|
|
170
|
+
|
|
171
|
+
for a in law["articles"]:
|
|
172
|
+
print(a["article_no"], a["chapter_path"])
|
|
173
|
+
print(a["content"])
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Find which laws mention a term:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
for hit in db.search("營業稅", limit=10):
|
|
180
|
+
print(hit["law_name"], hit["article_no"])
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
List every tax law, using MOJ's own classification:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
for l in db.list_laws():
|
|
187
|
+
if "賦稅" in l["moj_category"]:
|
|
188
|
+
print(l["id"], l["name"])
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Chunking for RAG
|
|
192
|
+
|
|
193
|
+
Each article already carries its chapter path, so a chunk is interpretable on
|
|
194
|
+
its own without extra work:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
law = db.get_law("所得稅法")
|
|
198
|
+
|
|
199
|
+
for a in law["articles"]:
|
|
200
|
+
chunk = f"{law['name']} {a['article_no']}\n{a['chapter_path']}\n{a['content']}"
|
|
201
|
+
print(chunk)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Coverage
|
|
205
|
+
|
|
206
|
+
| dataset | records |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| laws, Chinese | 1,347 |
|
|
209
|
+
| orders, Chinese | 10,451 |
|
|
210
|
+
| laws, English | 972 |
|
|
211
|
+
| orders, English | 2,206 |
|
|
212
|
+
|
|
213
|
+
English records are a separate, smaller corpus — not a translation of every
|
|
214
|
+
Chinese record. A law present in both shares the same `law_id`, so
|
|
215
|
+
`get_law(id, lang="en")` gives you the English text of the same statute.
|
|
216
|
+
|
|
217
|
+
## Notes
|
|
218
|
+
|
|
219
|
+
- **Concurrency.** WAL mode is on, so many processes can read at once. Only
|
|
220
|
+
`refresh()` writes.
|
|
221
|
+
- **Search.** FTS5 with the `trigram` tokenizer, which is what makes Chinese
|
|
222
|
+
substring search work: the default `unicode61` treats an unbroken run of CJK
|
|
223
|
+
as one token, which would find 50 articles for 所得稅 instead of ~1,400.
|
|
224
|
+
Queries under 3 characters fall back to `LIKE`.
|
|
225
|
+
- **Storage.** Plain SQLite tables (`laws`, `articles`), so the file is readable
|
|
226
|
+
from any language, not only Python.
|
|
227
|
+
|
|
228
|
+
## Data source and licensing
|
|
229
|
+
|
|
230
|
+
`twlaw` itself is MIT licensed, and the package ships **no legal data**. Laws
|
|
231
|
+
and regulations are downloaded at runtime by `refresh()` from the
|
|
232
|
+
[MOJ Open API](https://law.moj.gov.tw/api/swagger/index.html).
|
|
233
|
+
|
|
234
|
+
That data is published by the Ministry of Justice under the
|
|
235
|
+
[Open Government Data License v1.0](https://data.gov.tw/license)
|
|
236
|
+
(政府資料開放授權條款-第1版), which permits free reuse, modification and
|
|
237
|
+
redistribution — **provided you attribute the source**. If you ship a product
|
|
238
|
+
built on this data, credit it, for example:
|
|
239
|
+
|
|
240
|
+
> Data source: Laws & Regulations Database of the Republic of China (Taiwan),
|
|
241
|
+
> Ministry of Justice — https://law.moj.gov.tw/
|
|
242
|
+
|
|
243
|
+
Two things worth knowing if you build on this:
|
|
244
|
+
|
|
245
|
+
- **The MOJ database is authoritative; this is a convenience mirror.** `twlaw`
|
|
246
|
+
reconstructs chapter hierarchy and splits articles, which is ordinary
|
|
247
|
+
permitted adaptation — but the result is a *parsed* view. Cite the official
|
|
248
|
+
text for anything that matters.
|
|
249
|
+
- **Don't misrepresent the content.** The license asks that the data not be
|
|
250
|
+
altered in ways that make the displayed information contradict the original.
|
|
251
|
+
|
|
252
|
+
This section is a summary, not legal advice. Read the license if the
|
|
253
|
+
distinction matters to you.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "twlaw"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Queryable local access to Taiwan's national laws and regulations database"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"taiwan",
|
|
11
|
+
"law",
|
|
12
|
+
"legal",
|
|
13
|
+
"regulations",
|
|
14
|
+
"sqlite",
|
|
15
|
+
"全國法規資料庫",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Legal Industry",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Database",
|
|
27
|
+
"Topic :: Text Processing :: Indexing",
|
|
28
|
+
"Natural Language :: Chinese (Traditional)",
|
|
29
|
+
"Natural Language :: English",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"requests>=2.34.2",
|
|
33
|
+
"truststore>=0.10.4",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[[project.authors]]
|
|
37
|
+
name = "Yihsuan Chen"
|
|
38
|
+
email = "yhc0712.tw@gmail.com"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/yhc0712/twlaw"
|
|
42
|
+
Repository = "https://github.com/yhc0712/twlaw"
|
|
43
|
+
Issues = "https://github.com/yhc0712/twlaw/issues"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = ["pytest>=8"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
pythonpath = ["tests"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "twlaw"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Queryable local access to Taiwan's national laws and regulations database"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Yihsuan Chen", email = "yhc0712.tw@gmail.com" }]
|
|
10
|
+
keywords = ["taiwan", "law", "legal", "regulations", "sqlite", "全國法規資料庫"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Intended Audience :: Legal Industry",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Database",
|
|
21
|
+
"Topic :: Text Processing :: Indexing",
|
|
22
|
+
"Natural Language :: Chinese (Traditional)",
|
|
23
|
+
"Natural Language :: English",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"requests>=2.34.2",
|
|
27
|
+
"truststore>=0.10.4",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/yhc0712/twlaw"
|
|
32
|
+
Repository = "https://github.com/yhc0712/twlaw"
|
|
33
|
+
Issues = "https://github.com/yhc0712/twlaw/issues"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest>=8"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Queryable local access to Taiwan's national laws and regulations database.
|
|
2
|
+
|
|
3
|
+
from twlaw import LawDB
|
|
4
|
+
|
|
5
|
+
db = LawDB()
|
|
6
|
+
db.refresh() # download and build the local store
|
|
7
|
+
db.search("所得稅") # full-text search across articles
|
|
8
|
+
db.get_law("所得稅法") # one law with chapter-aware articles
|
|
9
|
+
db.get_article("所得稅法", "4-1") # 第 4 條之一
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .db import DEFAULT_PATH, SCHEMA_VERSION, LawDB
|
|
13
|
+
from .fetch import CATEGORIES, LANGS
|
|
14
|
+
|
|
15
|
+
__all__ = ["LawDB", "DEFAULT_PATH", "SCHEMA_VERSION", "CATEGORIES", "LANGS"]
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Local SQLite store and query API."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .fetch import CATEGORIES, LANGS, fetch_dataset
|
|
7
|
+
from .parse import iter_rows
|
|
8
|
+
|
|
9
|
+
DEFAULT_PATH = Path.home() / ".twlaw" / "law.db"
|
|
10
|
+
|
|
11
|
+
# Bump whenever the table layout changes. A database built by an older version
|
|
12
|
+
# is discarded and rebuilt rather than migrated: it is a cache of an upstream
|
|
13
|
+
# dataset, so re-downloading is simpler and cheaper than writing migrations.
|
|
14
|
+
SCHEMA_VERSION = 1
|
|
15
|
+
|
|
16
|
+
_SCHEMA = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS laws (
|
|
18
|
+
id TEXT NOT NULL,
|
|
19
|
+
lang TEXT NOT NULL,
|
|
20
|
+
category TEXT NOT NULL,
|
|
21
|
+
name TEXT NOT NULL,
|
|
22
|
+
name_en TEXT,
|
|
23
|
+
level TEXT,
|
|
24
|
+
moj_category TEXT,
|
|
25
|
+
modified_date TEXT,
|
|
26
|
+
effective_date TEXT,
|
|
27
|
+
effective_note TEXT,
|
|
28
|
+
abandon_note TEXT,
|
|
29
|
+
foreword TEXT,
|
|
30
|
+
histories TEXT,
|
|
31
|
+
url TEXT,
|
|
32
|
+
update_date TEXT,
|
|
33
|
+
PRIMARY KEY (id, lang)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS articles (
|
|
37
|
+
rowid INTEGER PRIMARY KEY,
|
|
38
|
+
law_id TEXT NOT NULL,
|
|
39
|
+
lang TEXT NOT NULL,
|
|
40
|
+
seq INTEGER NOT NULL,
|
|
41
|
+
article_no TEXT,
|
|
42
|
+
article_key TEXT,
|
|
43
|
+
content TEXT,
|
|
44
|
+
chapter_path TEXT,
|
|
45
|
+
UNIQUE (law_id, lang, seq)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_articles_key ON articles(law_id, lang, article_key);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_laws_name ON laws(name);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_laws_category ON laws(category, lang);
|
|
52
|
+
|
|
53
|
+
-- 'trigram' rather than the default tokenizer: unicode61 treats an unbroken
|
|
54
|
+
-- run of CJK as a single token, so searching 所得稅 would miss every article
|
|
55
|
+
-- where it sits mid-phrase (50 hits instead of ~1400). Trigram indexes
|
|
56
|
+
-- 3-character windows, which matches Chinese substrings correctly. Its
|
|
57
|
+
-- tradeoff is that queries shorter than 3 characters never match, so search()
|
|
58
|
+
-- falls back to LIKE for those.
|
|
59
|
+
-- content='articles' makes this an external-content index: FTS stores only the
|
|
60
|
+
-- index, not a second copy of the text (which cost ~180MB).
|
|
61
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
|
62
|
+
content,
|
|
63
|
+
chapter_path,
|
|
64
|
+
content = 'articles',
|
|
65
|
+
content_rowid = 'rowid',
|
|
66
|
+
tokenize = 'trigram'
|
|
67
|
+
);
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
_FTS_MIN_QUERY = 3
|
|
71
|
+
|
|
72
|
+
_LAW_COLUMNS = (
|
|
73
|
+
"id", "lang", "category", "name", "name_en", "level", "moj_category",
|
|
74
|
+
"modified_date", "effective_date", "effective_note", "abandon_note",
|
|
75
|
+
"foreword", "histories", "url", "update_date",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LawDB:
|
|
80
|
+
"""Query interface over a local copy of the MOJ law database."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, path: str | Path | None = None):
|
|
83
|
+
self.path = Path(path) if path else DEFAULT_PATH
|
|
84
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
self._connect()
|
|
86
|
+
|
|
87
|
+
if self._stored_version() not in (0, SCHEMA_VERSION):
|
|
88
|
+
self._rebuild()
|
|
89
|
+
self._conn.executescript(_SCHEMA)
|
|
90
|
+
self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
91
|
+
|
|
92
|
+
def _stored_version(self) -> int:
|
|
93
|
+
"""0 for a database this version can still use as-is, else its version."""
|
|
94
|
+
version = self._conn.execute("PRAGMA user_version").fetchone()[0]
|
|
95
|
+
if version:
|
|
96
|
+
return version
|
|
97
|
+
# Written before versioning existed; usable only if it already has the
|
|
98
|
+
# current columns.
|
|
99
|
+
columns = {r[1] for r in self._conn.execute("PRAGMA table_info(articles)")}
|
|
100
|
+
return 0 if not columns or "article_key" in columns else -1
|
|
101
|
+
|
|
102
|
+
def _connect(self) -> None:
|
|
103
|
+
self._conn = sqlite3.connect(self.path)
|
|
104
|
+
self._conn.row_factory = sqlite3.Row
|
|
105
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
106
|
+
|
|
107
|
+
def _rebuild(self) -> None:
|
|
108
|
+
"""Discard a database written by an incompatible version of twlaw.
|
|
109
|
+
|
|
110
|
+
Drops the objects rather than deleting the file: on Windows the file
|
|
111
|
+
cannot be unlinked while another process still has it open.
|
|
112
|
+
"""
|
|
113
|
+
with self._conn:
|
|
114
|
+
for kind, name in self._conn.execute(
|
|
115
|
+
"SELECT type, name FROM sqlite_master"
|
|
116
|
+
" WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'"
|
|
117
|
+
).fetchall():
|
|
118
|
+
self._conn.execute(f'DROP {kind} IF EXISTS "{name}"')
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
self._conn.close()
|
|
122
|
+
|
|
123
|
+
def __enter__(self):
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, *exc) -> None:
|
|
127
|
+
self.close()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_empty(self) -> bool:
|
|
131
|
+
return self._conn.execute("SELECT COUNT(*) FROM laws").fetchone()[0] == 0
|
|
132
|
+
|
|
133
|
+
def update_date(self, category: str = "law", lang: str = "zh") -> str | None:
|
|
134
|
+
row = self._conn.execute(
|
|
135
|
+
"SELECT update_date FROM laws WHERE category=? AND lang=? LIMIT 1",
|
|
136
|
+
(category, lang),
|
|
137
|
+
).fetchone()
|
|
138
|
+
return row[0] if row else None
|
|
139
|
+
|
|
140
|
+
def refresh(self, categories=CATEGORIES, langs=LANGS) -> dict[tuple[str, str], int]:
|
|
141
|
+
"""Re-download the given datasets and replace their rows.
|
|
142
|
+
|
|
143
|
+
Returns the number of laws stored per ``(category, lang)``.
|
|
144
|
+
"""
|
|
145
|
+
counts = {}
|
|
146
|
+
for category in categories:
|
|
147
|
+
for lang in langs:
|
|
148
|
+
dataset = fetch_dataset(category, lang)
|
|
149
|
+
counts[(category, lang)] = self._replace(dataset, category, lang)
|
|
150
|
+
# Replacing rows leaves the FTS index in many small segments; merging
|
|
151
|
+
# them keeps the file from growing on every refresh.
|
|
152
|
+
with self._conn:
|
|
153
|
+
self._conn.execute("INSERT INTO articles_fts (articles_fts) VALUES ('optimize')")
|
|
154
|
+
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
155
|
+
return counts
|
|
156
|
+
|
|
157
|
+
def _replace(self, dataset: dict, category: str, lang: str) -> int:
|
|
158
|
+
law_params = []
|
|
159
|
+
article_params = []
|
|
160
|
+
|
|
161
|
+
for law_row, article_rows in iter_rows(dataset, category, lang):
|
|
162
|
+
law_params.append(tuple(law_row[c] for c in _LAW_COLUMNS))
|
|
163
|
+
for a in article_rows:
|
|
164
|
+
article_params.append(
|
|
165
|
+
(
|
|
166
|
+
a["law_id"], lang, a["seq"], a["article_no"],
|
|
167
|
+
a["article_key"], a["content"], a["chapter_path"],
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
placeholders = ",".join("?" * len(_LAW_COLUMNS))
|
|
172
|
+
with self._conn:
|
|
173
|
+
# The FTS index mirrors `articles` by rowid, so drop its entries for
|
|
174
|
+
# the rows being replaced before those rows disappear.
|
|
175
|
+
self._conn.execute(
|
|
176
|
+
"INSERT INTO articles_fts (articles_fts, rowid, content, chapter_path)"
|
|
177
|
+
" SELECT 'delete', a.rowid, a.content, a.chapter_path FROM articles a"
|
|
178
|
+
" JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
|
|
179
|
+
" WHERE l.category = ? AND l.lang = ?",
|
|
180
|
+
(category, lang),
|
|
181
|
+
)
|
|
182
|
+
self._conn.execute(
|
|
183
|
+
"DELETE FROM articles WHERE lang = ? AND law_id IN"
|
|
184
|
+
" (SELECT id FROM laws WHERE category = ? AND lang = ?)",
|
|
185
|
+
(lang, category, lang),
|
|
186
|
+
)
|
|
187
|
+
self._conn.execute("DELETE FROM laws WHERE category=? AND lang=?", (category, lang))
|
|
188
|
+
|
|
189
|
+
self._conn.executemany(f"INSERT INTO laws VALUES ({placeholders})", law_params)
|
|
190
|
+
self._conn.executemany(
|
|
191
|
+
"INSERT INTO articles"
|
|
192
|
+
" (law_id, lang, seq, article_no, article_key, content, chapter_path)"
|
|
193
|
+
" VALUES (?,?,?,?,?,?,?)",
|
|
194
|
+
article_params,
|
|
195
|
+
)
|
|
196
|
+
self._conn.execute(
|
|
197
|
+
"INSERT INTO articles_fts (rowid, content, chapter_path)"
|
|
198
|
+
" SELECT a.rowid, a.content, a.chapter_path FROM articles a"
|
|
199
|
+
" JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
|
|
200
|
+
" WHERE l.category = ? AND l.lang = ?",
|
|
201
|
+
(category, lang),
|
|
202
|
+
)
|
|
203
|
+
return len(law_params)
|
|
204
|
+
|
|
205
|
+
def _ensure_data(self) -> None:
|
|
206
|
+
if self.is_empty:
|
|
207
|
+
raise RuntimeError("Local database is empty; call refresh() first.")
|
|
208
|
+
|
|
209
|
+
def list_laws(self, category: str | None = None, lang: str = "zh", name_like: str | None = None) -> list[dict]:
|
|
210
|
+
sql = "SELECT * FROM laws WHERE lang=?"
|
|
211
|
+
params: list = [lang]
|
|
212
|
+
if category:
|
|
213
|
+
sql += " AND category=?"
|
|
214
|
+
params.append(category)
|
|
215
|
+
if name_like:
|
|
216
|
+
sql += " AND name LIKE ?"
|
|
217
|
+
params.append(f"%{name_like}%")
|
|
218
|
+
sql += " ORDER BY name"
|
|
219
|
+
return [dict(r) for r in self._conn.execute(sql, params)]
|
|
220
|
+
|
|
221
|
+
def get_law(self, law: str, lang: str = "zh") -> dict | None:
|
|
222
|
+
"""Return one law with its articles, each carrying its chapter path.
|
|
223
|
+
|
|
224
|
+
``law`` may be a law name (``"所得稅法"``) or a MOJ law code
|
|
225
|
+
(``"G0340003"``). Names are matched exactly, never by prefix, because
|
|
226
|
+
法規 names nest — 所得稅法 is a prefix of 所得稅法施行細則.
|
|
227
|
+
"""
|
|
228
|
+
self._ensure_data()
|
|
229
|
+
row = self._conn.execute(
|
|
230
|
+
"SELECT * FROM laws WHERE lang=? AND (id=? OR name=? OR name_en=?)",
|
|
231
|
+
(lang, law, law, law),
|
|
232
|
+
).fetchone()
|
|
233
|
+
if row is None:
|
|
234
|
+
return None
|
|
235
|
+
result = dict(row)
|
|
236
|
+
result["articles"] = [
|
|
237
|
+
dict(r)
|
|
238
|
+
for r in self._conn.execute(
|
|
239
|
+
"SELECT seq, article_no, article_key, content, chapter_path FROM articles"
|
|
240
|
+
" WHERE law_id=? AND lang=? ORDER BY seq",
|
|
241
|
+
(result["id"], lang),
|
|
242
|
+
)
|
|
243
|
+
]
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
def get_article(self, law: str, article: str | int, lang: str = "zh") -> dict | None:
|
|
247
|
+
"""Return one article by its number, or ``None`` if there is no such article.
|
|
248
|
+
|
|
249
|
+
``article`` is the number as it is cited: ``4`` or ``"4"`` for 第 4 條,
|
|
250
|
+
``"4-1"`` for 第 4 條之一. ``law`` accepts a name or a law code, as in
|
|
251
|
+
:meth:`get_law`.
|
|
252
|
+
"""
|
|
253
|
+
self._ensure_data()
|
|
254
|
+
row = self._conn.execute(
|
|
255
|
+
"SELECT a.seq, a.article_no, a.article_key, a.content, a.chapter_path,"
|
|
256
|
+
" l.id AS law_id, l.name AS law_name"
|
|
257
|
+
" FROM articles a"
|
|
258
|
+
" JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
|
|
259
|
+
" WHERE a.lang = ? AND a.article_key = ?"
|
|
260
|
+
" AND (l.id = ? OR l.name = ? OR l.name_en = ?)",
|
|
261
|
+
(lang, str(article).strip(), law, law, law),
|
|
262
|
+
).fetchone()
|
|
263
|
+
return dict(row) if row else None
|
|
264
|
+
|
|
265
|
+
def search(
|
|
266
|
+
self,
|
|
267
|
+
query: str,
|
|
268
|
+
lang: str = "zh",
|
|
269
|
+
category: str | None = None,
|
|
270
|
+
limit: int = 50,
|
|
271
|
+
include_repealed: bool = False,
|
|
272
|
+
) -> list[dict]:
|
|
273
|
+
"""Full-text search over article content.
|
|
274
|
+
|
|
275
|
+
Returns article rows annotated with their law's id and name, best match
|
|
276
|
+
first. ``chapter_path`` is searchable but weighted far below ``content``
|
|
277
|
+
so that matching a chapter title alone does not outrank a real hit.
|
|
278
|
+
Repealed articles (``(刪除)`` / "(Deleted)") are excluded by default.
|
|
279
|
+
"""
|
|
280
|
+
self._ensure_data()
|
|
281
|
+
query = query.strip()
|
|
282
|
+
if not query:
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
select = (
|
|
286
|
+
"SELECT a.law_id, l.name AS law_name, l.category, a.seq,"
|
|
287
|
+
" a.article_no, a.article_key, a.content, a.chapter_path"
|
|
288
|
+
)
|
|
289
|
+
params: list = []
|
|
290
|
+
|
|
291
|
+
if len(query) >= _FTS_MIN_QUERY:
|
|
292
|
+
sql = (
|
|
293
|
+
f"{select}"
|
|
294
|
+
" FROM articles_fts f"
|
|
295
|
+
" JOIN articles a ON a.rowid = f.rowid"
|
|
296
|
+
" JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
|
|
297
|
+
" WHERE articles_fts MATCH ? AND a.lang = ?"
|
|
298
|
+
)
|
|
299
|
+
params += [f'"{query}"', lang]
|
|
300
|
+
order = " ORDER BY bm25(articles_fts, 10.0, 1.0)"
|
|
301
|
+
else:
|
|
302
|
+
# Trigram FTS cannot match queries this short.
|
|
303
|
+
sql = (
|
|
304
|
+
f"{select}"
|
|
305
|
+
" FROM articles a"
|
|
306
|
+
" JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
|
|
307
|
+
" WHERE a.lang = ? AND a.content LIKE ?"
|
|
308
|
+
)
|
|
309
|
+
params += [lang, f"%{query}%"]
|
|
310
|
+
order = " ORDER BY a.law_id, a.seq"
|
|
311
|
+
|
|
312
|
+
if category:
|
|
313
|
+
sql += " AND l.category = ?"
|
|
314
|
+
params.append(category)
|
|
315
|
+
if not include_repealed:
|
|
316
|
+
sql += " AND a.content NOT LIKE '%刪除%' AND a.content NOT LIKE '%(Deleted)%'"
|
|
317
|
+
|
|
318
|
+
params.append(limit)
|
|
319
|
+
return [dict(r) for r in self._conn.execute(sql + order + " LIMIT ?", params)]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Download raw law datasets from the MOJ Open API."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import zipfile
|
|
6
|
+
|
|
7
|
+
import truststore
|
|
8
|
+
|
|
9
|
+
truststore.inject_into_ssl()
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://law.moj.gov.tw/api"
|
|
14
|
+
|
|
15
|
+
LANGS = ("zh", "en")
|
|
16
|
+
CATEGORIES = ("law", "order")
|
|
17
|
+
|
|
18
|
+
_LANG_PATH = {"zh": "Ch", "en": "En"}
|
|
19
|
+
_CATEGORY_PATH = {"law": "Law", "order": "Order"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_dataset(category: str, lang: str, timeout: int = 120) -> dict:
|
|
23
|
+
"""Return the decoded dataset for one (category, lang) pair.
|
|
24
|
+
|
|
25
|
+
The endpoint serves a zip archive; the payload is the single .json member.
|
|
26
|
+
"""
|
|
27
|
+
if lang not in LANGS:
|
|
28
|
+
raise ValueError(f"lang must be one of {LANGS}, got {lang!r}")
|
|
29
|
+
if category not in CATEGORIES:
|
|
30
|
+
raise ValueError(f"category must be one of {CATEGORIES}, got {category!r}")
|
|
31
|
+
|
|
32
|
+
url = f"{BASE_URL}/{_LANG_PATH[lang]}/{_CATEGORY_PATH[category]}/JSON"
|
|
33
|
+
resp = requests.get(url, timeout=timeout)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
|
|
36
|
+
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
|
37
|
+
name = next(n for n in zf.namelist() if n.lower().endswith(".json"))
|
|
38
|
+
with zf.open(name) as fh:
|
|
39
|
+
# The MOJ files carry a UTF-8 BOM.
|
|
40
|
+
return json.loads(fh.read().decode("utf-8-sig"))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Normalize MOJ records into flat law/article rows.
|
|
2
|
+
|
|
3
|
+
MOJ interleaves structural headings and real articles in one article list:
|
|
4
|
+
``ArticleType == "C"`` rows are headings whose leading indentation encodes depth
|
|
5
|
+
(0=編, 3=章, 6=節, 9=款, 12=目), while ``ArticleType == "A"`` rows are articles
|
|
6
|
+
carrying no hierarchy of their own. Each article's position in the hierarchy is
|
|
7
|
+
therefore implicit, and is resolved here by walking the list and tracking the
|
|
8
|
+
heading stack.
|
|
9
|
+
|
|
10
|
+
The English datasets use the same layout but prefix every field name with
|
|
11
|
+
``Eng`` and omit the category/effective-date fields, so field access goes
|
|
12
|
+
through a per-language field map.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
_PCODE_RE = re.compile(r"pcode=([A-Za-z0-9]+)", re.IGNORECASE)
|
|
18
|
+
_INDENT_RE = re.compile(r"^(\s*)")
|
|
19
|
+
_INDENT_UNIT = 3
|
|
20
|
+
|
|
21
|
+
# "第 4-1 條" / "Article 4-1" -> "4-1". Both languages also use a bare number
|
|
22
|
+
# for a handful of appendix-style entries.
|
|
23
|
+
_ARTICLE_NO_RE = re.compile(r"(\d+(?:-\d+)?)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def article_key(article_no: str) -> str:
|
|
27
|
+
"""Return the citable number in an article label, e.g. ``第 4-1 條`` -> ``4-1``."""
|
|
28
|
+
match = _ARTICLE_NO_RE.search(article_no or "")
|
|
29
|
+
return match.group(1) if match else ""
|
|
30
|
+
|
|
31
|
+
# The two language variants name the same fields differently.
|
|
32
|
+
_FIELDS = {
|
|
33
|
+
"zh": {
|
|
34
|
+
"url": "LawURL",
|
|
35
|
+
"modified_date": "LawModifiedDate",
|
|
36
|
+
"abandon_note": "LawAbandonNote",
|
|
37
|
+
"foreword": "LawForeword",
|
|
38
|
+
"histories": "LawHistories",
|
|
39
|
+
"articles": "LawArticles",
|
|
40
|
+
"article_type": "ArticleType",
|
|
41
|
+
"article_no": "ArticleNo",
|
|
42
|
+
"article_content": "ArticleContent",
|
|
43
|
+
},
|
|
44
|
+
"en": {
|
|
45
|
+
"url": "EngLawURL",
|
|
46
|
+
"modified_date": "EngLawModifiedDate",
|
|
47
|
+
"abandon_note": "EngLawAbandonNote",
|
|
48
|
+
"foreword": "EngLawForeword",
|
|
49
|
+
"histories": "EngLawHistories",
|
|
50
|
+
"articles": "EngLawArticles",
|
|
51
|
+
"article_type": "EngArticleType",
|
|
52
|
+
"article_no": "EngArticleNo",
|
|
53
|
+
"article_content": "EngArticleContent",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pcode(law: dict, url_field: str) -> str | None:
|
|
59
|
+
match = _PCODE_RE.search(law.get(url_field, "") or "")
|
|
60
|
+
return match.group(1) if match else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _heading_depth(content: str) -> int:
|
|
64
|
+
return len(_INDENT_RE.match(content).group(1)) // _INDENT_UNIT
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def iter_rows(dataset: dict, category: str, lang: str):
|
|
68
|
+
"""Yield ``(law_row, article_rows)`` pairs for one fetched dataset."""
|
|
69
|
+
update_date = dataset.get("UpdateDate", "")
|
|
70
|
+
f = _FIELDS[lang]
|
|
71
|
+
|
|
72
|
+
for law in dataset.get("Laws", []):
|
|
73
|
+
law_id = _pcode(law, f["url"])
|
|
74
|
+
if law_id is None:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
law_row = {
|
|
78
|
+
"id": law_id,
|
|
79
|
+
"lang": lang,
|
|
80
|
+
"category": category,
|
|
81
|
+
"name": law.get("LawName", ""),
|
|
82
|
+
"name_en": law.get("EngLawName", ""),
|
|
83
|
+
"level": law.get("LawLevel", ""),
|
|
84
|
+
"moj_category": law.get("LawCategory", ""),
|
|
85
|
+
"modified_date": law.get(f["modified_date"], ""),
|
|
86
|
+
"effective_date": law.get("LawEffectiveDate", ""),
|
|
87
|
+
"effective_note": law.get("LawEffectiveNote", ""),
|
|
88
|
+
"abandon_note": law.get(f["abandon_note"], ""),
|
|
89
|
+
"foreword": law.get(f["foreword"], ""),
|
|
90
|
+
"histories": law.get(f["histories"], ""),
|
|
91
|
+
"url": law.get(f["url"], ""),
|
|
92
|
+
"update_date": update_date,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
article_rows = []
|
|
96
|
+
stack: list[str] = []
|
|
97
|
+
seq = 0
|
|
98
|
+
|
|
99
|
+
for entry in law.get(f["articles"]) or []:
|
|
100
|
+
content = entry.get(f["article_content"], "") or ""
|
|
101
|
+
|
|
102
|
+
if entry.get(f["article_type"]) == "C":
|
|
103
|
+
depth = _heading_depth(content)
|
|
104
|
+
del stack[depth:]
|
|
105
|
+
# Pad when a level is skipped so depth stays the index.
|
|
106
|
+
while len(stack) < depth:
|
|
107
|
+
stack.append("")
|
|
108
|
+
stack.append(content.strip())
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
article_no = (entry.get(f["article_no"]) or "").strip()
|
|
112
|
+
article_rows.append(
|
|
113
|
+
{
|
|
114
|
+
"law_id": law_id,
|
|
115
|
+
"seq": seq,
|
|
116
|
+
"article_no": article_no,
|
|
117
|
+
"article_key": article_key(article_no),
|
|
118
|
+
"content": content,
|
|
119
|
+
"chapter_path": " / ".join(p for p in stack if p),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
seq += 1
|
|
123
|
+
|
|
124
|
+
yield law_row, article_rows
|