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.
@@ -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()
@@ -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]