clip2table 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.
- clip2table/__init__.py +11 -0
- clip2table/__main__.py +11 -0
- clip2table/capabilities.py +86 -0
- clip2table/cards/__init__.py +1 -0
- clip2table/cards/builtin/admin-order-list.toml +55 -0
- clip2table/cards/builtin/generic-item-block.toml +57 -0
- clip2table/cards/builtin/job-list.toml +54 -0
- clip2table/cards/builtin/notice-list.toml +51 -0
- clip2table/cards/builtin/numbered-list.toml +47 -0
- clip2table/cards/builtin/samples/admin-order-list/orders.tsv.expect.tsv +6 -0
- clip2table/cards/builtin/samples/admin-order-list/orders.tsv.txt +6 -0
- clip2table/cards/builtin/samples/generic-item-block/gov-list.expect.tsv +4 -0
- clip2table/cards/builtin/samples/generic-item-block/gov-list.txt +20 -0
- clip2table/cards/builtin/samples/job-list/hr-roles.expect.tsv +5 -0
- clip2table/cards/builtin/samples/job-list/hr-roles.txt +23 -0
- clip2table/cards/builtin/samples/notice-list/list-page.expect.tsv +4 -0
- clip2table/cards/builtin/samples/notice-list/list-page.txt +17 -0
- clip2table/cards/builtin/samples/numbered-list/project-list.expect.tsv +5 -0
- clip2table/cards/builtin/samples/numbered-list/project-list.txt +4 -0
- clip2table/cards/loader.py +50 -0
- clip2table/cards/regress.py +184 -0
- clip2table/cards/schema.py +299 -0
- clip2table/channels/__init__.py +1 -0
- clip2table/channels/cdp.py +299 -0
- clip2table/channels/cfhtml.py +144 -0
- clip2table/channels/clipboard.py +137 -0
- clip2table/channels/http.py +169 -0
- clip2table/channels/keyboard.py +492 -0
- clip2table/channels/ws.py +147 -0
- clip2table/cli.py +1106 -0
- clip2table/core.py +135 -0
- clip2table/dataset.py +1358 -0
- clip2table/errors.py +35 -0
- clip2table/flow.py +224 -0
- clip2table/harvest.py +982 -0
- clip2table/ladder.py +229 -0
- clip2table/models.py +205 -0
- clip2table/pipeline/__init__.py +1 -0
- clip2table/pipeline/assemble.py +116 -0
- clip2table/pipeline/extract.py +524 -0
- clip2table/pipeline/htmltable.py +243 -0
- clip2table/pipeline/infer.py +461 -0
- clip2table/pipeline/normalize.py +61 -0
- clip2table/pipeline/segment.py +539 -0
- clip2table/pipeline/sniff.py +80 -0
- clip2table/render/__init__.py +1 -0
- clip2table/render/base.py +65 -0
- clip2table/render/text.py +77 -0
- clip2table/render/xlsx.py +213 -0
- clip2table/report.py +267 -0
- clip2table-0.1.0.dist-info/METADATA +220 -0
- clip2table-0.1.0.dist-info/RECORD +57 -0
- clip2table-0.1.0.dist-info/WHEEL +5 -0
- clip2table-0.1.0.dist-info/entry_points.txt +2 -0
- clip2table-0.1.0.dist-info/licenses/LICENSE +21 -0
- clip2table-0.1.0.dist-info/licenses/NOTICE.md +114 -0
- clip2table-0.1.0.dist-info/top_level.txt +1 -0
clip2table/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""clip2table - turn any screen you can select into a table.
|
|
2
|
+
|
|
3
|
+
Copy with a real browser or a real program, read the clipboard, cut the text
|
|
4
|
+
into records, write a table. Nothing is guessed: a value that is not in the
|
|
5
|
+
source stays empty.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
clip2table/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`python -m clip2table` - the path that works before install.
|
|
2
|
+
|
|
3
|
+
The console script is the normal way in, but a cloned repo has no console
|
|
4
|
+
script yet, and `python -m clip2table` failing with "No module named
|
|
5
|
+
clip2table.__main__" is a bad first minute.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .cli import main
|
|
10
|
+
|
|
11
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""One capability table, consulted by validation, dispatch and help text.
|
|
2
|
+
|
|
3
|
+
The rule this module exists to enforce: **a card may not declare something the
|
|
4
|
+
program cannot execute.** Before this table, `kind = "dom"` and `kind = "json"`
|
|
5
|
+
passed validation, ran, and returned None -- so "the page has no such element",
|
|
6
|
+
"the regex found nothing" and "this build cannot do that at all" all arrived at
|
|
7
|
+
the user as the same empty cell. An empty cell that means three different things
|
|
8
|
+
is not a missing value, it is a missing explanation.
|
|
9
|
+
|
|
10
|
+
Keeping the table in one place means the three consumers cannot drift: the schema
|
|
11
|
+
rejects with the same reason the dispatcher would give, and `cards show` prints
|
|
12
|
+
the same reason a help screen would.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
from .errors import InputError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Capability(NamedTuple):
|
|
22
|
+
kind: str
|
|
23
|
+
channels: tuple[str, ...] # channels that can actually fill this kind
|
|
24
|
+
note: str # what to use instead, or how it behaves
|
|
25
|
+
implemented: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Kinds that appear in KINDS but are not wired to any producer. They stay in the
|
|
29
|
+
# vocabulary so old cards fail loudly rather than silently producing empties;
|
|
30
|
+
# when a producer lands, flip `implemented` and add the channels.
|
|
31
|
+
CAPABILITIES: dict[str, Capability] = {
|
|
32
|
+
"cell": Capability("cell", ("http", "browser", "desktop"),
|
|
33
|
+
"表格的一列(HTML 表格或 TSV),列边界是事实"),
|
|
34
|
+
"line": Capability("line", ("http", "browser", "desktop"),
|
|
35
|
+
"记录的第 n 行,负数从末尾数"),
|
|
36
|
+
"search": Capability("search", ("http", "browser", "desktop"),
|
|
37
|
+
"匹配正则的那一行,再取捕获组"),
|
|
38
|
+
"span": Capability("span", ("http", "browser", "desktop"),
|
|
39
|
+
"两个标记之间的文本,可跨行"),
|
|
40
|
+
"const": Capability("const", ("http", "browser", "desktop"),
|
|
41
|
+
"固定值,只用于标注来源,从不拿来填数据"),
|
|
42
|
+
"dom": Capability(
|
|
43
|
+
"dom", (), "还没实现。现在是空值,但空值和「页面上没有这个元素」"
|
|
44
|
+
"看起来一模一样。改用 line/search/span/cell —— 复制的文本里"
|
|
45
|
+
"一样有这些字段", implemented=False),
|
|
46
|
+
"json": Capability(
|
|
47
|
+
"json", (), "还没实现。现在是空值。若目标是 JSON 接口,先把它存成文件,"
|
|
48
|
+
"用 run --text-file 走文本路径", implemented=False),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
CHANNEL_CAPS: dict[str, tuple[str, ...]] = {
|
|
52
|
+
"http": ("cell", "line", "search", "span", "const"),
|
|
53
|
+
"browser": ("cell", "line", "search", "span", "const"),
|
|
54
|
+
"desktop": ("cell", "line", "search", "span", "const"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_kind(kind: str, channel: str | None, where: str) -> None:
|
|
59
|
+
"""Refuse a field the runtime could not fill. Raises with a way forward."""
|
|
60
|
+
cap = CAPABILITIES.get(kind)
|
|
61
|
+
if cap is None:
|
|
62
|
+
raise InputError(f"{where}: kind={kind!r} 不在可用原语里")
|
|
63
|
+
if not cap.implemented:
|
|
64
|
+
raise InputError(
|
|
65
|
+
f"{where}: kind={kind!r} 还没实现,声明它只会得到空值",
|
|
66
|
+
hint=cap.note)
|
|
67
|
+
if channel and kind not in CHANNEL_CAPS.get(channel, CHANNEL_CAPS["http"]):
|
|
68
|
+
usable = "、".join(CHANNEL_CAPS.get(channel, ()))
|
|
69
|
+
raise InputError(
|
|
70
|
+
f"{where}: kind={kind!r} 在 {channel} 通道上不可用",
|
|
71
|
+
hint=f"{channel} 通道能用的原语是 {usable};{cap.note}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unsupported_kinds() -> list[str]:
|
|
75
|
+
"""Kinds declared by the vocabulary but not wired up yet."""
|
|
76
|
+
return [k for k, c in CAPABILITIES.items() if not c.implemented]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def describe() -> list[str]:
|
|
80
|
+
"""Human/agent-facing capability list, shared by doctor and cards show."""
|
|
81
|
+
out = []
|
|
82
|
+
for kind, cap in CAPABILITIES.items():
|
|
83
|
+
where = "、".join(cap.channels) if cap.channels else "(无)"
|
|
84
|
+
state = "" if cap.implemented else " ← 未实现,卡的校验会直接拒绝"
|
|
85
|
+
out.append(f"{kind:7s} {where:26s} {cap.note}{state}")
|
|
86
|
+
return out
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Card loading and validation."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
schema = 1
|
|
2
|
+
name = "admin-order-list"
|
|
3
|
+
channel = "browser"
|
|
4
|
+
|
|
5
|
+
# 后台列表页:表格是事实,列边界就是字段边界。电商/招聘/财务后台结构一致。
|
|
6
|
+
#
|
|
7
|
+
# 为什么 mode = "lines":后台表格复制出来时,行已经是分开的(HTML 的 <tr>
|
|
8
|
+
# 变成一行 tab 分隔文本,Excel 区域粘贴也是每行一条)。再按锚点分组不是规则,
|
|
9
|
+
# 是 bug——`single` 曾把 11 行表格粘成 1 条记录,第一条字段是表头文字,
|
|
10
|
+
# 还报了两个字段 100% 填充。
|
|
11
|
+
|
|
12
|
+
samples = "samples/admin-order-list"
|
|
13
|
+
|
|
14
|
+
[fetch]
|
|
15
|
+
url = "https://example.com/admin/orders"
|
|
16
|
+
ready = "load"
|
|
17
|
+
scroll = { until = "stable", max = 40, pause_ms = 600 }
|
|
18
|
+
|
|
19
|
+
[segment]
|
|
20
|
+
mode = "lines"
|
|
21
|
+
# 后台表格复制出来时第一行通常是列名(「订单号 / 下单时间 / 金额 …」)。
|
|
22
|
+
# 不跳过它就会多出一条「order_no=订单号、amount=空」的记录,amount 的
|
|
23
|
+
# 填充率也因此被拉低——一份干净的表看起来像规则失效。
|
|
24
|
+
skip_header = true
|
|
25
|
+
|
|
26
|
+
[fields.order_no]
|
|
27
|
+
kind = "cell"
|
|
28
|
+
col = 0
|
|
29
|
+
|
|
30
|
+
[fields.created]
|
|
31
|
+
kind = "cell"
|
|
32
|
+
col = 1
|
|
33
|
+
|
|
34
|
+
[fields.amount]
|
|
35
|
+
kind = "cell"
|
|
36
|
+
col = 2
|
|
37
|
+
type = "float"
|
|
38
|
+
|
|
39
|
+
[fields.status]
|
|
40
|
+
kind = "cell"
|
|
41
|
+
col = 3
|
|
42
|
+
|
|
43
|
+
[fields.note]
|
|
44
|
+
kind = "cell"
|
|
45
|
+
col = 4
|
|
46
|
+
|
|
47
|
+
[dedupe]
|
|
48
|
+
key = ["order_no"]
|
|
49
|
+
on_collision = "report_only"
|
|
50
|
+
|
|
51
|
+
[paging]
|
|
52
|
+
style = "click"
|
|
53
|
+
next = "a.next"
|
|
54
|
+
max_pages = 50
|
|
55
|
+
pause_ms = 1200
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
schema = 1
|
|
2
|
+
name = "generic-item-block"
|
|
3
|
+
channel = "desktop"
|
|
4
|
+
|
|
5
|
+
# 通用「条目块」:一条短标题行,紧跟一行日期,然后是正文/规格。
|
|
6
|
+
# 电商列表、政务名录、文献列表、房源条目都是这个形状,改的只是字段名。
|
|
7
|
+
#
|
|
8
|
+
# 它要求的形状很具体:日期必须是记录里的**第二行**。
|
|
9
|
+
# 招聘列表那种「标题/公司/地点/薪资/日期」日期在末尾的版式**不适用**本卡——
|
|
10
|
+
# 硬套会把薪资行当成标题。那种版式直接用 `clip2table infer`,S1 会正确切出
|
|
11
|
+
# 五列(已用 fixtures/sample-hr.txt 验证)。用错卡不是数据问题,是选错工具;
|
|
12
|
+
# 报告里的「未识别 N 行」就是它给你的信号。
|
|
13
|
+
|
|
14
|
+
samples = "samples/generic-item-block"
|
|
15
|
+
|
|
16
|
+
[segment]
|
|
17
|
+
mode = "anchor"
|
|
18
|
+
anchor = '^(?!\s).{1,40}$'
|
|
19
|
+
# The date line must be a BARE date (optionally with a time) and nothing else.
|
|
20
|
+
# A loose date prefix ('^\d{4}-\d{1,2}') also matches a body line that merely
|
|
21
|
+
# begins with a date - "2026-03-12 补记:扶手略窄" - and then that body line
|
|
22
|
+
# becomes a record start, splitting one record into two.
|
|
23
|
+
anchor_next = '^\d{4}\s*[-/.年]\s*\d{1,2}\s*(?:[-/.月]\s*\d{1,2}\s*[日号]?)?(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\s*$'
|
|
24
|
+
min_lines = 2
|
|
25
|
+
|
|
26
|
+
[fields.title]
|
|
27
|
+
kind = "line"
|
|
28
|
+
index = 0
|
|
29
|
+
|
|
30
|
+
[fields.date]
|
|
31
|
+
kind = "search"
|
|
32
|
+
pattern = '(?P<v>\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{4}年\d{1,2}月\d{1,2}日)'
|
|
33
|
+
scope = "head"
|
|
34
|
+
window = 2
|
|
35
|
+
type = "date"
|
|
36
|
+
|
|
37
|
+
[fields.body]
|
|
38
|
+
kind = "span"
|
|
39
|
+
from = "after:date"
|
|
40
|
+
# The `?` makes the stop marker optional: cut before 规格 when the record has
|
|
41
|
+
# one, run to the end of the record when it does not. Strict (no `?`) is the
|
|
42
|
+
# default because a vanished marker usually means the page changed under the
|
|
43
|
+
# card -- and a strict bound that cannot be found returns empty, not the whole
|
|
44
|
+
# record. This card serves both shapes, so it has to declare that explicitly.
|
|
45
|
+
to = "before?:^规格[::]"
|
|
46
|
+
type = "text"
|
|
47
|
+
|
|
48
|
+
[fields.spec]
|
|
49
|
+
kind = "search"
|
|
50
|
+
pattern = '^规格[::]\s*(?P<v>.+)$'
|
|
51
|
+
|
|
52
|
+
[dedupe]
|
|
53
|
+
key = ["title", "date", "body"]
|
|
54
|
+
on_collision = "report_only"
|
|
55
|
+
|
|
56
|
+
[paging]
|
|
57
|
+
style = "none"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
schema = 1
|
|
2
|
+
name = "job-list"
|
|
3
|
+
channel = "desktop"
|
|
4
|
+
|
|
5
|
+
# 招聘列表:一条五段——职位 / 公司 / 地点经验 / 薪资 / 发布日期,日期在**最后**。
|
|
6
|
+
#
|
|
7
|
+
# 这张卡的日期锚点是反向的:记录以「日期行」结束,下一条从日期行的下一行开始。
|
|
8
|
+
# 用 notice-list 那种「第二行是日期」的卡去套它,会把薪资行当成标题——所以
|
|
9
|
+
# 这里给的是分开的一张卡,而不是把两张卡揉成一个万能正则。
|
|
10
|
+
#
|
|
11
|
+
# 采集方式:浏览器里 F12 → Elements 之外最省事的形态是「全选复制」,
|
|
12
|
+
# 或者用 desktop 通道对一个只显示招聘列表的窗口按 Ctrl+A。
|
|
13
|
+
|
|
14
|
+
samples = "samples/job-list"
|
|
15
|
+
|
|
16
|
+
[harvest]
|
|
17
|
+
select_all = true
|
|
18
|
+
timeout_ms = 5000
|
|
19
|
+
|
|
20
|
+
[segment]
|
|
21
|
+
# 空行分段。为什么不用 anchor:锚点机制只能把「命中行」当作记录的**起点**,
|
|
22
|
+
# 而这张卡的日期在末尾——写成 anchor 会让每一行都成为起点,然后 min_lines
|
|
23
|
+
# 把单行记录全部滤掉,结果是 0 行、全部未识别(试过,就是这样)。
|
|
24
|
+
# 招聘站复制出来的块之间通常有空行,所以用 blank;没有空行就先用 infer。
|
|
25
|
+
mode = "blank"
|
|
26
|
+
|
|
27
|
+
[fields.title]
|
|
28
|
+
kind = "line"
|
|
29
|
+
index = 0
|
|
30
|
+
|
|
31
|
+
[fields.company]
|
|
32
|
+
kind = "line"
|
|
33
|
+
index = 1
|
|
34
|
+
|
|
35
|
+
[fields.location]
|
|
36
|
+
kind = "line"
|
|
37
|
+
index = 2
|
|
38
|
+
|
|
39
|
+
[fields.salary]
|
|
40
|
+
kind = "line"
|
|
41
|
+
index = 3
|
|
42
|
+
|
|
43
|
+
[fields.date]
|
|
44
|
+
kind = "line"
|
|
45
|
+
index = -1
|
|
46
|
+
type = "date"
|
|
47
|
+
pattern = '(?P<v>\d{4}[-/.]\d{1,2}[-/.]\d{1,2})'
|
|
48
|
+
|
|
49
|
+
[dedupe]
|
|
50
|
+
key = ["title", "company"]
|
|
51
|
+
on_collision = "report_only"
|
|
52
|
+
|
|
53
|
+
[paging]
|
|
54
|
+
style = "none"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
schema = 1
|
|
2
|
+
name = "notice-list"
|
|
3
|
+
channel = "browser"
|
|
4
|
+
|
|
5
|
+
# 列表页:一屏多条,每条是「标题 / 日期 / 正文」三行一段,还带一个可选的规格行。
|
|
6
|
+
#
|
|
7
|
+
# 这是最通用的列表形状:采购公告、招标信息、政策文件、活动报名、文章列表
|
|
8
|
+
# 都是它。字段名按你的业务改,规则不用动。
|
|
9
|
+
#
|
|
10
|
+
# 分页交给 [paging]:点「下一页」链接,连续 2 页没有新增就自动停。
|
|
11
|
+
|
|
12
|
+
samples = "samples/notice-list"
|
|
13
|
+
|
|
14
|
+
[harvest]
|
|
15
|
+
select_all = true
|
|
16
|
+
timeout_ms = 5000
|
|
17
|
+
|
|
18
|
+
[segment]
|
|
19
|
+
mode = "anchor"
|
|
20
|
+
anchor = '^(?!\s).{1,60}$'
|
|
21
|
+
# 第二行必须是**裸日期**,不能是「以日期开头的正文」——否则正文里提到
|
|
22
|
+
# 2026-03-12 的那一行会被当成新记录的开头,一条记录被切成两条。
|
|
23
|
+
anchor_next = '^\d{4}\s*[-/.年]\s*\d{1,2}\s*(?:[-/.月]\s*\d{1,2}\s*[日号]?)?\s*$'
|
|
24
|
+
min_lines = 3
|
|
25
|
+
|
|
26
|
+
[fields.title]
|
|
27
|
+
kind = "line"
|
|
28
|
+
index = 0
|
|
29
|
+
|
|
30
|
+
[fields.date]
|
|
31
|
+
kind = "line"
|
|
32
|
+
index = 1
|
|
33
|
+
type = "date"
|
|
34
|
+
|
|
35
|
+
[fields.body]
|
|
36
|
+
kind = "line"
|
|
37
|
+
index = 2
|
|
38
|
+
|
|
39
|
+
[fields.spec]
|
|
40
|
+
kind = "search"
|
|
41
|
+
pattern = '^规格[::]\s*(?P<v>.+)$'
|
|
42
|
+
|
|
43
|
+
[dedupe]
|
|
44
|
+
key = ["title"]
|
|
45
|
+
on_collision = "report_only"
|
|
46
|
+
|
|
47
|
+
[paging]
|
|
48
|
+
style = "click"
|
|
49
|
+
next = "a.next"
|
|
50
|
+
max_pages = 5
|
|
51
|
+
pause_ms = 1200
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
schema = 1
|
|
2
|
+
name = "numbered-list"
|
|
3
|
+
channel = "desktop"
|
|
4
|
+
|
|
5
|
+
# 编号清单:一行一条,「1. 事项名 —— 进度/时间」。
|
|
6
|
+
#
|
|
7
|
+
# 这是台账、任务清单、问题列表、验收项清单最常见的形状——每条只占一行,
|
|
8
|
+
# 和上面那些「多行一段」的卡完全不同。这里靠行首编号切分,不靠空行也不靠
|
|
9
|
+
# 日期,因为这类清单通常把编号当唯一的分界信号。
|
|
10
|
+
|
|
11
|
+
samples = "samples/numbered-list"
|
|
12
|
+
|
|
13
|
+
[target]
|
|
14
|
+
# 从任何窗口里复制都行:改成你那个窗口标题里的一段文字。
|
|
15
|
+
# 比如 "工作台账 - Notepad"、"任务列表 - Excel"。
|
|
16
|
+
title = "工作台账"
|
|
17
|
+
restore = true
|
|
18
|
+
|
|
19
|
+
[harvest]
|
|
20
|
+
select_all = true
|
|
21
|
+
timeout_ms = 5000
|
|
22
|
+
|
|
23
|
+
[segment]
|
|
24
|
+
mode = "anchor"
|
|
25
|
+
# 行首 1. / 2. / 1、/ (1) 都算一条新记录。行内的「——」不当分界:
|
|
26
|
+
# 它就是这条记录里的一个分隔符,切成两条反而会把一条记录拆散。
|
|
27
|
+
anchor = '^\s*(?:\d{1,3}[.、))]|[((]\d{1,3}[))])'
|
|
28
|
+
min_lines = 1
|
|
29
|
+
|
|
30
|
+
[fields.no]
|
|
31
|
+
kind = "search"
|
|
32
|
+
pattern = '^\s*(?P<v>\d{1,3})'
|
|
33
|
+
|
|
34
|
+
[fields.item]
|
|
35
|
+
kind = "search"
|
|
36
|
+
pattern = '^\s*\d{1,3}[.、))]\s*(?P<v>.+?)\s*(?:——|--|—)\s*'
|
|
37
|
+
|
|
38
|
+
[fields.progress]
|
|
39
|
+
kind = "search"
|
|
40
|
+
pattern = '(?:——|--|—)\s*(?P<v>.+)$'
|
|
41
|
+
|
|
42
|
+
[dedupe]
|
|
43
|
+
key = ["no"]
|
|
44
|
+
on_collision = "report_only"
|
|
45
|
+
|
|
46
|
+
[paging]
|
|
47
|
+
style = "none"
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
title date body spec
|
|
2
|
+
临江区市场监督管理局后勤服务中心 2026-01-15 统一社会信用代码 91330100MA2XXXX1X\n法定代表人:周某\n登记状态:存续\n注册资本:500 万元
|
|
3
|
+
滨河区行政审批服务中心 2026-01-09 统一社会信用代码 91330100MA2XXXX2X\n法定代表人:李某\n登记状态:注销\n注册资本:120 万元
|
|
4
|
+
新桥镇农业农村办公室 2025-12-28 统一社会信用代码 91330100MA2XXXX3X\n法定代表人:吴某\n登记状态:存续\n注册资本:80 万元
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
临江区市场监督管理局后勤服务中心
|
|
2
|
+
2026-01-15
|
|
3
|
+
统一社会信用代码 91330100MA2XXXX1X
|
|
4
|
+
法定代表人:周某
|
|
5
|
+
登记状态:存续
|
|
6
|
+
注册资本:500 万元
|
|
7
|
+
|
|
8
|
+
滨河区行政审批服务中心
|
|
9
|
+
2026-01-09
|
|
10
|
+
统一社会信用代码 91330100MA2XXXX2X
|
|
11
|
+
法定代表人:李某
|
|
12
|
+
登记状态:注销
|
|
13
|
+
注册资本:120 万元
|
|
14
|
+
|
|
15
|
+
新桥镇农业农村办公室
|
|
16
|
+
2025-12-28
|
|
17
|
+
统一社会信用代码 91330100MA2XXXX3X
|
|
18
|
+
法定代表人:吴某
|
|
19
|
+
登记状态:存续
|
|
20
|
+
注册资本:80 万元
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
后端开发工程师(Python)
|
|
2
|
+
星野科技
|
|
3
|
+
杭州 · 3-5 年
|
|
4
|
+
25-40K · 14薪
|
|
5
|
+
2026-03-09
|
|
6
|
+
|
|
7
|
+
数据分析师
|
|
8
|
+
青禾数据
|
|
9
|
+
上海 · 1-3 年
|
|
10
|
+
18-25K · 13薪
|
|
11
|
+
2026-03-08
|
|
12
|
+
|
|
13
|
+
产品经理(B端)
|
|
14
|
+
同衡软件
|
|
15
|
+
深圳 · 5-10 年
|
|
16
|
+
30-50K · 15薪
|
|
17
|
+
2026-03-06
|
|
18
|
+
|
|
19
|
+
测试开发工程师
|
|
20
|
+
临江智能
|
|
21
|
+
成都 · 3-5 年
|
|
22
|
+
20-30K · 13薪
|
|
23
|
+
2026-03-05
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Card loader: TOML in, Card out. No magic lookup order beyond the obvious."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..errors import InputError
|
|
8
|
+
from .schema import Card, parse_card
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_card(path: str | Path) -> Card:
|
|
12
|
+
p = Path(path)
|
|
13
|
+
if not p.exists():
|
|
14
|
+
raise InputError(f"找不到卡:{p}", hint="用 `clip2table cards list` 看内置卡")
|
|
15
|
+
if p.suffix.lower() != ".toml":
|
|
16
|
+
raise InputError(f"卡必须是 .toml 文件:{p}")
|
|
17
|
+
try:
|
|
18
|
+
raw = p.read_bytes()
|
|
19
|
+
except OSError as e:
|
|
20
|
+
raise InputError(f"读不了卡 {p}:{e}") from None
|
|
21
|
+
try:
|
|
22
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
23
|
+
except UnicodeDecodeError:
|
|
24
|
+
raise InputError(f"卡不是 UTF-8:{p}") from None
|
|
25
|
+
except tomllib.TOMLDecodeError as e:
|
|
26
|
+
raise InputError(f"卡不是合法 TOML:{p}\n {e}") from None
|
|
27
|
+
return parse_card(data, str(p))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def builtin_dir() -> Path:
|
|
31
|
+
return Path(__file__).with_name("builtin")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_builtin() -> list[Path]:
|
|
35
|
+
d = builtin_dir()
|
|
36
|
+
if not d.is_dir():
|
|
37
|
+
return []
|
|
38
|
+
return sorted(d.glob("*.toml"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_card(name_or_path: str) -> Card:
|
|
42
|
+
"""A path, or a builtin card name without the .toml suffix."""
|
|
43
|
+
p = Path(name_or_path)
|
|
44
|
+
if p.exists():
|
|
45
|
+
return load_card(p)
|
|
46
|
+
candidate = builtin_dir() / f"{name_or_path}.toml"
|
|
47
|
+
if candidate.exists():
|
|
48
|
+
return load_card(candidate)
|
|
49
|
+
raise InputError(f"找不到卡 {name_or_path!r}",
|
|
50
|
+
hint="给一个 .toml 路径,或用 `clip2table cards list` 里的名字")
|