jevrake 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.
- jevrake/__init__.py +13 -0
- jevrake/_internal/__init__.py +1 -0
- jevrake/_internal/api.py +138 -0
- jevrake/_internal/client.py +81 -0
- jevrake/_internal/collect.py +210 -0
- jevrake/_internal/convert.py +78 -0
- jevrake/_internal/dom.py +200 -0
- jevrake/_internal/exceptions.py +25 -0
- jevrake/_internal/fetch.py +75 -0
- jevrake/_internal/jev.py +181 -0
- jevrake/_internal/models.py +131 -0
- jevrake/_internal/schema.py +105 -0
- jevrake/_internal/version.py +2 -0
- jevrake/cli.py +127 -0
- jevrake/py.typed +0 -0
- jevrake-0.1.0.dist-info/METADATA +93 -0
- jevrake-0.1.0.dist-info/RECORD +20 -0
- jevrake-0.1.0.dist-info/WHEEL +4 -0
- jevrake-0.1.0.dist-info/entry_points.txt +3 -0
- jevrake-0.1.0.dist-info/licenses/LICENSE +21 -0
jevrake/_internal/dom.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Turn raw HTML into a ``ParsedPage``.
|
|
2
|
+
|
|
3
|
+
Removes scripts, navigation, footers and hidden elements, then collects the page title, the main
|
|
4
|
+
text, and each element that holds text together with its nearby label. It also finds blocks that
|
|
5
|
+
repeat (such as the items of a list page).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TypeGuard
|
|
12
|
+
|
|
13
|
+
from selectolax.parser import HTMLParser, Node
|
|
14
|
+
|
|
15
|
+
from .models import ParsedPage, TextElement
|
|
16
|
+
|
|
17
|
+
MAX_MAIN_TEXT_CHARS = 8000
|
|
18
|
+
|
|
19
|
+
REMOVED_TAGS = ("script", "style", "nav", "footer", "noscript", "template", "svg", "iframe")
|
|
20
|
+
_HIDDEN_STYLE_RE = re.compile(r"display\s*:\s*none|visibility\s*:\s*hidden", re.IGNORECASE)
|
|
21
|
+
_BLOCK_TAGS = {
|
|
22
|
+
"p", "div", "li", "tr", "td", "th", "dt", "dd", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
23
|
+
"section", "article", "main", "header", "aside", "table", "ul", "ol", "dl", "br",
|
|
24
|
+
"blockquote", "pre", "figure", "figcaption", "form", "label",
|
|
25
|
+
} # fmt: skip
|
|
26
|
+
MAX_LABEL_CHARS = 30
|
|
27
|
+
MIN_REPEATED_BLOCKS = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_element(node: Node | None) -> TypeGuard[Node]:
|
|
31
|
+
return node is not None and not node.tag.startswith(("-", "_"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_hidden(node: Node) -> bool:
|
|
35
|
+
attrs = node.attributes
|
|
36
|
+
if "hidden" in attrs or attrs.get("aria-hidden") == "true":
|
|
37
|
+
return True
|
|
38
|
+
return bool(_HIDDEN_STYLE_RE.search(attrs.get("style") or ""))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clean_html(html: str) -> HTMLParser:
|
|
42
|
+
"""Parse ``html`` and remove non-content elements and hidden elements."""
|
|
43
|
+
tree = HTMLParser(html)
|
|
44
|
+
for tag in REMOVED_TAGS:
|
|
45
|
+
for node in tree.css(tag):
|
|
46
|
+
node.decompose()
|
|
47
|
+
if tree.body is not None:
|
|
48
|
+
hidden = [n for n in tree.body.traverse() if _is_element(n) and _is_hidden(n)]
|
|
49
|
+
# Nodes inside a hidden ancestor are removed together with it.
|
|
50
|
+
for node in hidden:
|
|
51
|
+
if not any(_is_hidden(a) for a in _get_ancestors(node)):
|
|
52
|
+
node.decompose()
|
|
53
|
+
return tree
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_ancestors(node: Node) -> list[Node]:
|
|
57
|
+
result: list[Node] = []
|
|
58
|
+
current = node.parent
|
|
59
|
+
while _is_element(current):
|
|
60
|
+
result.append(current)
|
|
61
|
+
current = current.parent
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_own_text(node: Node) -> str:
|
|
66
|
+
parts = [child.text() for child in node.iter(include_text=True) if child.tag == "-text"]
|
|
67
|
+
return " ".join(" ".join(parts).split())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_full_text(node: Node) -> str:
|
|
71
|
+
return " ".join(node.text(deep=True, separator=" ").split())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _find_previous_element(node: Node) -> Node | None:
|
|
75
|
+
sibling = node.prev
|
|
76
|
+
while sibling is not None and not _is_element(sibling):
|
|
77
|
+
sibling = sibling.prev
|
|
78
|
+
return sibling
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _keep_if_short(text: str) -> str | None:
|
|
82
|
+
text = text.strip().rstrip("::").strip()
|
|
83
|
+
return text if 0 < len(text) <= MAX_LABEL_CHARS else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_label(node: Node) -> str | None:
|
|
87
|
+
"""Find a nearby label: table header, definition term, or a short preceding element.
|
|
88
|
+
|
|
89
|
+
Looks at the node itself and its parent only; going further up tends to pick unrelated text.
|
|
90
|
+
"""
|
|
91
|
+
current: Node | None = node
|
|
92
|
+
for _ in range(2):
|
|
93
|
+
if not _is_element(current):
|
|
94
|
+
return None
|
|
95
|
+
if current.tag in ("td", "th", "dd"):
|
|
96
|
+
prev = _find_previous_element(current)
|
|
97
|
+
if prev is not None and prev.tag in ("th", "td", "dt"):
|
|
98
|
+
return _keep_if_short(_get_full_text(prev))
|
|
99
|
+
prev = _find_previous_element(current)
|
|
100
|
+
if prev is not None:
|
|
101
|
+
label = _keep_if_short(_get_full_text(prev))
|
|
102
|
+
if label:
|
|
103
|
+
return label
|
|
104
|
+
for attr in ("aria-label", "itemprop"):
|
|
105
|
+
if current.attributes.get(attr):
|
|
106
|
+
return _keep_if_short(current.attributes[attr] or "")
|
|
107
|
+
current = current.parent
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _find_heading_level(node: Node) -> int | None:
|
|
112
|
+
current: Node | None = node
|
|
113
|
+
for _ in range(3):
|
|
114
|
+
if not _is_element(current):
|
|
115
|
+
return None
|
|
116
|
+
if re.fullmatch(r"h[1-6]", current.tag):
|
|
117
|
+
return int(current.tag[1])
|
|
118
|
+
current = current.parent
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _build_main_text(body: Node) -> str:
|
|
123
|
+
lines: list[str] = []
|
|
124
|
+
buffer: list[str] = []
|
|
125
|
+
|
|
126
|
+
def add_line() -> None:
|
|
127
|
+
line = " ".join(" ".join(buffer).split())
|
|
128
|
+
if line:
|
|
129
|
+
lines.append(line)
|
|
130
|
+
buffer.clear()
|
|
131
|
+
|
|
132
|
+
for node in body.traverse(include_text=True):
|
|
133
|
+
if node.tag == "-text":
|
|
134
|
+
buffer.append(node.text())
|
|
135
|
+
elif node.tag in _BLOCK_TAGS:
|
|
136
|
+
add_line()
|
|
137
|
+
add_line()
|
|
138
|
+
return "\n".join(lines)[:MAX_MAIN_TEXT_CHARS]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _find_repeated_blocks(body: Node) -> list[list[Node]]:
|
|
142
|
+
"""Find children of the same parent that share a tag and class, at least 3 of them."""
|
|
143
|
+
groups: list[list[Node]] = []
|
|
144
|
+
for parent in body.traverse():
|
|
145
|
+
if not _is_element(parent):
|
|
146
|
+
continue
|
|
147
|
+
by_shape: dict[tuple[str, str], list[Node]] = {}
|
|
148
|
+
for child in parent.iter():
|
|
149
|
+
if _is_element(child) and _get_full_text(child):
|
|
150
|
+
shape = (child.tag, child.attributes.get("class") or "")
|
|
151
|
+
by_shape.setdefault(shape, []).append(child)
|
|
152
|
+
groups.extend(b for b in by_shape.values() if len(b) >= MIN_REPEATED_BLOCKS)
|
|
153
|
+
return groups
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_page(html: str) -> ParsedPage:
|
|
157
|
+
raw = HTMLParser(html)
|
|
158
|
+
title_node = raw.css_first("title")
|
|
159
|
+
title = " ".join(title_node.text().split()) if title_node else None
|
|
160
|
+
og_node = raw.css_first('meta[property="og:title"]')
|
|
161
|
+
og_title = (og_node.attributes.get("content") or "").strip() or None if og_node else None
|
|
162
|
+
|
|
163
|
+
tree = clean_html(html)
|
|
164
|
+
body = tree.body
|
|
165
|
+
if body is None:
|
|
166
|
+
return ParsedPage(title=title or None, og_title=og_title, main_text="", elements=[])
|
|
167
|
+
|
|
168
|
+
block_nodes = _find_repeated_blocks(body)
|
|
169
|
+
block_index = {
|
|
170
|
+
node.mem_id: (g, b) for g, group in enumerate(block_nodes) for b, node in enumerate(group)
|
|
171
|
+
}
|
|
172
|
+
repeated_groups: list[list[list[TextElement]]] = [[[] for _ in g] for g in block_nodes]
|
|
173
|
+
|
|
174
|
+
elements: list[TextElement] = []
|
|
175
|
+
for node in body.traverse():
|
|
176
|
+
if not _is_element(node) or node.tag == "body":
|
|
177
|
+
continue
|
|
178
|
+
text = _get_own_text(node)
|
|
179
|
+
if not text:
|
|
180
|
+
continue
|
|
181
|
+
element = TextElement(
|
|
182
|
+
text=text,
|
|
183
|
+
tag=node.tag,
|
|
184
|
+
attrs={k: v or "" for k, v in node.attributes.items()},
|
|
185
|
+
label=_find_label(node),
|
|
186
|
+
heading_level=_find_heading_level(node),
|
|
187
|
+
)
|
|
188
|
+
elements.append(element)
|
|
189
|
+
for container in (node, *_get_ancestors(node)):
|
|
190
|
+
if container.mem_id in block_index:
|
|
191
|
+
g, b = block_index[container.mem_id]
|
|
192
|
+
repeated_groups[g][b].append(element)
|
|
193
|
+
|
|
194
|
+
return ParsedPage(
|
|
195
|
+
title=title or None,
|
|
196
|
+
og_title=og_title,
|
|
197
|
+
main_text=_build_main_text(body),
|
|
198
|
+
elements=elements,
|
|
199
|
+
repeated_groups=repeated_groups,
|
|
200
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exceptions raised by jevrake."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SchemaError(ValueError):
|
|
5
|
+
"""A field definition is invalid."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoercionError(ValueError):
|
|
9
|
+
"""Candidate text cannot be converted to the target's type."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FetchError(Exception):
|
|
13
|
+
"""A page could not be fetched."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JevError(Exception):
|
|
17
|
+
"""A Jev request failed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JevAuthError(JevError):
|
|
21
|
+
"""Authentication failed (missing key, 401, 403). The whole run should stop."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MissingAPIKeyError(JevAuthError):
|
|
25
|
+
"""The API key environment variable is not set."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Download the HTML of each page.
|
|
2
|
+
|
|
3
|
+
Waits ``delay`` seconds between requests to the same site so the site is not overloaded.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .exceptions import FetchError
|
|
15
|
+
from .version import USER_AGENT
|
|
16
|
+
|
|
17
|
+
DEFAULT_DELAY = 1.0
|
|
18
|
+
TIMEOUT = 30.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Fetcher(Protocol):
|
|
22
|
+
def fetch(self, url: str) -> str:
|
|
23
|
+
"""Return the HTML of ``url``. Raises :class:`FetchError`."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_origin(url: str) -> str:
|
|
27
|
+
parts = urlsplit(url)
|
|
28
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HttpxFetcher:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
delay: float = DEFAULT_DELAY,
|
|
36
|
+
transport: httpx.BaseTransport | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.delay = delay
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
headers={"User-Agent": USER_AGENT},
|
|
41
|
+
timeout=TIMEOUT,
|
|
42
|
+
follow_redirects=True,
|
|
43
|
+
transport=transport,
|
|
44
|
+
)
|
|
45
|
+
self._last_access: dict[str, float] = {}
|
|
46
|
+
|
|
47
|
+
def _get_with_delay(self, url: str) -> httpx.Response:
|
|
48
|
+
"""GET ``url`` after waiting so requests to the same host are ``delay`` seconds apart."""
|
|
49
|
+
origin = _get_origin(url)
|
|
50
|
+
last = self._last_access.get(origin)
|
|
51
|
+
if last is not None:
|
|
52
|
+
wait = last + self.delay - time.monotonic()
|
|
53
|
+
if wait > 0:
|
|
54
|
+
time.sleep(wait)
|
|
55
|
+
self._last_access[origin] = time.monotonic()
|
|
56
|
+
return self._client.get(url)
|
|
57
|
+
|
|
58
|
+
def fetch(self, url: str) -> str:
|
|
59
|
+
if urlsplit(url).scheme not in ("http", "https"):
|
|
60
|
+
raise FetchError(f"not an http/https URL: {url}")
|
|
61
|
+
try:
|
|
62
|
+
response = self._get_with_delay(url)
|
|
63
|
+
except httpx.TimeoutException:
|
|
64
|
+
raise FetchError(f"request timed out: {url}") from None
|
|
65
|
+
except httpx.HTTPError as e:
|
|
66
|
+
raise FetchError(f"request failed: {url}: {e}") from None
|
|
67
|
+
if not response.is_success:
|
|
68
|
+
raise FetchError(f"HTTP {response.status_code}: {url}")
|
|
69
|
+
content_type = response.headers.get("content-type", "")
|
|
70
|
+
if content_type and not any(t in content_type for t in ("html", "xml", "text/plain")):
|
|
71
|
+
raise FetchError(f"not an HTML page ({content_type}): {url}")
|
|
72
|
+
return response.text
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self._client.close()
|
jevrake/_internal/jev.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Build the question for Jev, and read Jev's answer.
|
|
2
|
+
|
|
3
|
+
Has no network access; sending the question is done by client.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from typesafe_sdk import Choice
|
|
12
|
+
|
|
13
|
+
from .models import (
|
|
14
|
+
ExtractedValue,
|
|
15
|
+
ExtractionCandidate,
|
|
16
|
+
ExtractionOutcome,
|
|
17
|
+
ExtractionTarget,
|
|
18
|
+
JevRequest,
|
|
19
|
+
JevResponse,
|
|
20
|
+
ParsedPage,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
NONE_CHOICE = "__none__"
|
|
24
|
+
NONE_DESCRIPTION = "None of these is the requested value."
|
|
25
|
+
MAX_CHOICES = 255
|
|
26
|
+
MAX_DESCRIPTION_TEXT = 200
|
|
27
|
+
|
|
28
|
+
CHOICE_INSTRUCTIONS = (
|
|
29
|
+
'Which option is "{description}"? Choose the option that represents the {name} of this page.'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
ITEM_CHOICE_INSTRUCTIONS = (
|
|
33
|
+
'Which option is "{description}"? All options come from one item of a list on this page; '
|
|
34
|
+
"choose the option that represents the {name} of that item."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_question_key(name: str, item: int | None) -> str:
|
|
39
|
+
"""``price`` for a single-record page, ``item3.price`` for item 3 of a list page."""
|
|
40
|
+
return name if item is None else f"item{item}.{name}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def describe_candidate(candidate: ExtractionCandidate) -> str:
|
|
44
|
+
"""e.g. ``"¥3,980" | <span class=price> | nearby label: 販売価格``"""
|
|
45
|
+
text = candidate.text
|
|
46
|
+
if len(text) > MAX_DESCRIPTION_TEXT:
|
|
47
|
+
text = text[:MAX_DESCRIPTION_TEXT] + "…"
|
|
48
|
+
parts = [json.dumps(text, ensure_ascii=False)]
|
|
49
|
+
ctx = candidate.context
|
|
50
|
+
if ctx.get("source"):
|
|
51
|
+
parts.append(ctx["source"])
|
|
52
|
+
elif ctx.get("tag"):
|
|
53
|
+
parts.append(ctx["tag"])
|
|
54
|
+
if ctx.get("heading"):
|
|
55
|
+
parts.append(f"heading {ctx['heading']}")
|
|
56
|
+
if ctx.get("label"):
|
|
57
|
+
parts.append(f"nearby label: {ctx['label']}")
|
|
58
|
+
return " | ".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_jev_state(url: str, page: ParsedPage) -> dict[str, str]:
|
|
62
|
+
return {
|
|
63
|
+
"url": url,
|
|
64
|
+
"page_title": page.title or page.og_title or "",
|
|
65
|
+
"main_text": page.main_text,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_jev_request(
|
|
70
|
+
url: str,
|
|
71
|
+
page: ParsedPage,
|
|
72
|
+
targets: list[ExtractionTarget],
|
|
73
|
+
candidates_by_item: dict[int | None, dict[str, list[ExtractionCandidate]]],
|
|
74
|
+
) -> JevRequest:
|
|
75
|
+
"""Build one request with a question per item and target.
|
|
76
|
+
|
|
77
|
+
``candidates_by_item`` maps an item number (``None`` for a single-record page) to the
|
|
78
|
+
candidates of each target in that item.
|
|
79
|
+
"""
|
|
80
|
+
request = JevRequest(state=build_jev_state(url, page))
|
|
81
|
+
for item, candidates in candidates_by_item.items():
|
|
82
|
+
for target in targets:
|
|
83
|
+
key = build_question_key(target.name, item)
|
|
84
|
+
chosen = candidates.get(target.name, [])[: MAX_CHOICES - 1]
|
|
85
|
+
if not chosen:
|
|
86
|
+
request.skipped.append(key)
|
|
87
|
+
continue
|
|
88
|
+
names = [f"c{i}" for i in range(len(chosen))]
|
|
89
|
+
criteria = {n: describe_candidate(c) for n, c in zip(names, chosen, strict=True)}
|
|
90
|
+
criteria[NONE_CHOICE] = NONE_DESCRIPTION
|
|
91
|
+
template = CHOICE_INSTRUCTIONS if item is None else ITEM_CHOICE_INSTRUCTIONS
|
|
92
|
+
request.questions[key] = Choice(
|
|
93
|
+
instructions=template.format(
|
|
94
|
+
description=target.description, name=target.name, item=item
|
|
95
|
+
),
|
|
96
|
+
criteria=criteria,
|
|
97
|
+
)
|
|
98
|
+
request.options[key] = dict(zip(names, chosen, strict=True))
|
|
99
|
+
return request
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_jev_payload(request: JevRequest, model: str) -> dict[str, Any]:
|
|
103
|
+
"""The request body as it would be sent (for ``--dry-run``)."""
|
|
104
|
+
return {
|
|
105
|
+
"model": model,
|
|
106
|
+
"state": request.state,
|
|
107
|
+
"questions": {
|
|
108
|
+
name: q.model_dump(mode="json", exclude_none=True)
|
|
109
|
+
for name, q in request.questions.items()
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _record_missing_value(
|
|
115
|
+
target: ExtractionTarget, values: dict[str, Any], errors: list[str], reason: str
|
|
116
|
+
) -> None:
|
|
117
|
+
if not target.is_optional:
|
|
118
|
+
errors.append(f"{target.name}: {reason}")
|
|
119
|
+
elif target.required:
|
|
120
|
+
values[target.name] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parse_jev_response(
|
|
124
|
+
request: JevRequest,
|
|
125
|
+
response: JevResponse,
|
|
126
|
+
targets: list[ExtractionTarget],
|
|
127
|
+
min_confidence: float,
|
|
128
|
+
item: int | None = None,
|
|
129
|
+
) -> ExtractionOutcome:
|
|
130
|
+
"""Turn Jev's answers for one item (``None`` for a single-record page) into values."""
|
|
131
|
+
fields: dict[str, ExtractedValue] = {}
|
|
132
|
+
values: dict[str, Any] = {}
|
|
133
|
+
errors: list[str] = []
|
|
134
|
+
|
|
135
|
+
for target in targets:
|
|
136
|
+
name = target.name
|
|
137
|
+
key = build_question_key(name, item)
|
|
138
|
+
if key in request.skipped:
|
|
139
|
+
fields[name] = ExtractedValue(value=None, confidence=None, probability=0.0, source=None)
|
|
140
|
+
_record_missing_value(target, values, errors, "no candidates found on the page")
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
answer = response.choices.get(key)
|
|
144
|
+
if answer is None:
|
|
145
|
+
errors.append(f"{name}: missing from the Jev response")
|
|
146
|
+
continue
|
|
147
|
+
probability = answer.probabilities.get(answer.choice, answer.confidence)
|
|
148
|
+
if answer.choice == NONE_CHOICE:
|
|
149
|
+
fields[name] = ExtractedValue(
|
|
150
|
+
value=None, confidence=answer.confidence, probability=probability, source=None
|
|
151
|
+
)
|
|
152
|
+
_record_missing_value(target, values, errors, "no matching value (Jev chose __none__)")
|
|
153
|
+
continue
|
|
154
|
+
candidate = request.options[key].get(answer.choice)
|
|
155
|
+
if candidate is None:
|
|
156
|
+
errors.append(f"{name}: Jev returned an unknown choice: {answer.choice!r}")
|
|
157
|
+
continue
|
|
158
|
+
if answer.confidence < min_confidence:
|
|
159
|
+
fields[name] = ExtractedValue(
|
|
160
|
+
value=None,
|
|
161
|
+
confidence=answer.confidence,
|
|
162
|
+
probability=probability,
|
|
163
|
+
source=candidate.text,
|
|
164
|
+
)
|
|
165
|
+
_record_missing_value(
|
|
166
|
+
target,
|
|
167
|
+
values,
|
|
168
|
+
errors,
|
|
169
|
+
f"confidence {answer.confidence:.2f} is below {min_confidence:.2f} "
|
|
170
|
+
f"(rejected {candidate.text!r})",
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
fields[name] = ExtractedValue(
|
|
174
|
+
value=candidate.normalized_value,
|
|
175
|
+
confidence=answer.confidence,
|
|
176
|
+
probability=probability,
|
|
177
|
+
source=candidate.text,
|
|
178
|
+
)
|
|
179
|
+
values[name] = candidate.normalized_value
|
|
180
|
+
|
|
181
|
+
return ExtractionOutcome(fields=fields, values=values, errors=errors)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Data passed between the steps of ``rake()``, and the results returned to users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
from typesafe_sdk import Choice
|
|
9
|
+
|
|
10
|
+
ExtractionKind = Literal["str", "int", "float", "decimal"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExtractionTarget(BaseModel):
|
|
14
|
+
"""Internal description of one field to extract."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(frozen=True)
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
kind: ExtractionKind
|
|
21
|
+
nullable: bool = False
|
|
22
|
+
required: bool = True
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_optional(self) -> bool:
|
|
26
|
+
return self.nullable or not self.required
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TargetDefinition(BaseModel):
|
|
30
|
+
"""One entry of the CLI ``--fields`` JSON."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
description: str | None = None
|
|
35
|
+
type: ExtractionKind = "str"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TextElement(BaseModel):
|
|
39
|
+
"""An element that directly contains text, with information about its surroundings."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
|
42
|
+
|
|
43
|
+
text: str
|
|
44
|
+
tag: str
|
|
45
|
+
attrs: dict[str, str]
|
|
46
|
+
label: str | None = None
|
|
47
|
+
heading_level: int | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ParsedPage(BaseModel):
|
|
51
|
+
model_config = ConfigDict(frozen=True)
|
|
52
|
+
|
|
53
|
+
title: str | None
|
|
54
|
+
og_title: str | None
|
|
55
|
+
main_text: str
|
|
56
|
+
elements: list[TextElement]
|
|
57
|
+
repeated_groups: list[list[list[TextElement]]] = []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ExtractionCandidate(BaseModel):
|
|
61
|
+
"""A value found on the page that may be the answer for a field."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(frozen=True)
|
|
64
|
+
|
|
65
|
+
text: str
|
|
66
|
+
normalized_value: Any
|
|
67
|
+
context: dict[str, str] = {}
|
|
68
|
+
score: float = 0.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ItemBlock(BaseModel):
|
|
72
|
+
"""The part of a page that holds one record: the whole page, or one item of a list page."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(frozen=True)
|
|
75
|
+
|
|
76
|
+
elements: list[TextElement]
|
|
77
|
+
is_whole_page: bool
|
|
78
|
+
candidates: dict[str, list[ExtractionCandidate]] = {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class JevRequest(BaseModel):
|
|
82
|
+
state: dict[str, str]
|
|
83
|
+
questions: dict[str, Choice] = {}
|
|
84
|
+
options: dict[str, dict[str, ExtractionCandidate]] = {}
|
|
85
|
+
skipped: list[str] = []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class JevChoiceAnswer(BaseModel):
|
|
89
|
+
model_config = ConfigDict(frozen=True)
|
|
90
|
+
|
|
91
|
+
choice: str
|
|
92
|
+
confidence: float
|
|
93
|
+
probabilities: dict[str, float]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class JevResponse(BaseModel):
|
|
97
|
+
"""The parts of a Jev response jevrake uses, independent of the SDK types."""
|
|
98
|
+
|
|
99
|
+
model_config = ConfigDict(frozen=True)
|
|
100
|
+
|
|
101
|
+
choices: dict[str, JevChoiceAnswer] = {}
|
|
102
|
+
request_id: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ExtractedValue(BaseModel):
|
|
106
|
+
value: Any | None
|
|
107
|
+
confidence: float | None
|
|
108
|
+
probability: float
|
|
109
|
+
source: str | None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ExtractionOutcome(BaseModel):
|
|
113
|
+
fields: dict[str, ExtractedValue]
|
|
114
|
+
values: dict[str, Any]
|
|
115
|
+
errors: list[str]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RakeItem[T: BaseModel](BaseModel):
|
|
119
|
+
"""One record found on a page."""
|
|
120
|
+
|
|
121
|
+
data: T
|
|
122
|
+
fields: dict[str, ExtractedValue]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RakeResult[T: BaseModel](BaseModel):
|
|
126
|
+
"""All records found on one page."""
|
|
127
|
+
|
|
128
|
+
url: str
|
|
129
|
+
items: list[RakeItem[T]]
|
|
130
|
+
request_ids: list[str]
|
|
131
|
+
errors: list[str]
|