html-reader-llm 0.0.1__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,547 @@
1
+ """HTML simplification pipeline — transforms raw HTML into LLM-friendly format.
2
+
3
+ Fixed 7-step pipeline:
4
+ 1. Strip noise tags (script/style/noscript etc.) + comments
5
+ 2. Simplify media (img/video/audio → placeholders)
6
+ 3. Clean attributes (blacklist/whitelist dual mode) + URL normalization
7
+ 4. Truncate repeated list items
8
+ 5. Truncate text nodes (regular + script inline)
9
+ 6. Unwrap bare wrapper tags
10
+ 7. Clean whitespace
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from pathlib import PurePosixPath
18
+ from typing import Any
19
+ from urllib.parse import urlparse
20
+
21
+ from selectolax.lexbor import LexborHTMLParser
22
+
23
+ from html_reader_llm import settings # noqa: E402 — relative import for package copy
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # --- Noise tags: entire subtree removed ---
28
+ NOISE_TAGS = frozenset(
29
+ {
30
+ "style",
31
+ "noscript",
32
+ "iframe",
33
+ "object",
34
+ "embed",
35
+ "base",
36
+ "font",
37
+ "link",
38
+ "br",
39
+ "hr",
40
+ "header",
41
+ "nav",
42
+ "footer",
43
+ "svg",
44
+ "path",
45
+ }
46
+ )
47
+
48
+ # --- Keep tags: never unwrapped by bare-tag removal ---
49
+ KEEP_TAGS = frozenset(
50
+ {
51
+ "html",
52
+ "head",
53
+ "body",
54
+ "title",
55
+ "meta",
56
+ "table",
57
+ "thead",
58
+ "tbody",
59
+ "tfoot",
60
+ "tr",
61
+ "th",
62
+ "td",
63
+ "ul",
64
+ "ol",
65
+ "li",
66
+ "dl",
67
+ "dt",
68
+ "dd",
69
+ "h1",
70
+ "h2",
71
+ "h3",
72
+ "h4",
73
+ "h5",
74
+ "h6",
75
+ "p",
76
+ "a",
77
+ "img",
78
+ }
79
+ )
80
+
81
+ # --- Structural tags: protect parent from unwrap ---
82
+ STRUCTURE_TAGS = frozenset(
83
+ {
84
+ "ul",
85
+ "ol",
86
+ "li",
87
+ "dl",
88
+ "dt",
89
+ "dd",
90
+ "table",
91
+ "thead",
92
+ "tbody",
93
+ "tfoot",
94
+ "tr",
95
+ "th",
96
+ "td",
97
+ }
98
+ )
99
+
100
+ # --- Truncatable item tags ---
101
+ TRUNCATABLE_ITEM_TAGS = frozenset({"li", "tr"})
102
+
103
+ # --- Unit regex for text truncation ---
104
+ _UNIT_RE = re.compile(
105
+ r"\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?"
106
+ r"|\d{1,2}:\d{2}(?::\d{2})?"
107
+ r"|[\u4e00-\u9fff\u3400-\u4dbf]"
108
+ r"|[a-zA-Z]+"
109
+ r"|\d+"
110
+ )
111
+
112
+ _MULTI_WS_RE = re.compile(r"\s+")
113
+ _SHORTENED_PLACEHOLDER = "[省略{n}字]"
114
+
115
+ # --- Default noise CSS selectors ---
116
+ DEFAULT_NOISE_SELECTORS: tuple[str, ...] = (
117
+ ".header",
118
+ ".footer",
119
+ ".nav",
120
+ ".navbar",
121
+ ".sidebar",
122
+ "#sidebar",
123
+ ".ad",
124
+ ".ads",
125
+ ".advertisement",
126
+ "[class*='banner']",
127
+ "[class*='cookie']",
128
+ "[class*='popup']",
129
+ "[class*='modal']",
130
+ "[id*='ad-']",
131
+ )
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Helpers
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def _walk_nodes(node: Any) -> Any:
140
+ """Iterative DFS over DOM tree, yielding all nodes including text nodes.
141
+
142
+ selectolax lexbor's traverse() skips text nodes,
143
+ so we manually walk via first_child / next.
144
+ Explicit stack avoids recursion depth issues on deep DOMs.
145
+ """
146
+ stack = [node]
147
+ while stack:
148
+ current = stack.pop()
149
+ yield current
150
+ children: list[Any] = []
151
+ child = current.first_child
152
+ while child is not None:
153
+ children.append(child)
154
+ child = child.next
155
+ stack.extend(reversed(children))
156
+
157
+
158
+ def _normalize_url(url: str) -> str:
159
+ """Convert a URL to its last path segment, stripping query and fragment.
160
+
161
+ Examples:
162
+ /static/jquery.min.js → jquery.min.js
163
+ https://cdn.example.com/a.js → a.js
164
+ /page?id=1#section → page
165
+ / → /
166
+ """
167
+ if not url or url.strip() == "/":
168
+ return url.strip() if url else ""
169
+ try:
170
+ parsed = urlparse(url)
171
+ path = parsed.path
172
+ except Exception: # noqa: BLE001
173
+ path = url
174
+ basename = PurePosixPath(path).name
175
+ return basename if basename else path
176
+
177
+
178
+ def _human_size(data_len: int) -> str:
179
+ """Convert byte count to human-readable size string."""
180
+ if data_len < 1024:
181
+ return f"{data_len}B"
182
+ if data_len < 1024 * 1024:
183
+ return f"{data_len / 1024:.1f}KB"
184
+ return f"{data_len / (1024 * 1024):.1f}MB"
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Step 1: Strip noise tags + comments
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def _strip_noise(parser: LexborHTMLParser) -> None:
193
+ """Remove noise tag subtrees and comment nodes."""
194
+ to_remove = []
195
+ for node in _walk_nodes(parser.root):
196
+ if node.is_comment_node:
197
+ to_remove.append(node)
198
+ continue
199
+ if not node.is_element_node:
200
+ continue
201
+ if node.tag.lower() in NOISE_TAGS:
202
+ to_remove.append(node)
203
+ for node in to_remove:
204
+ node.remove()
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Step 2: Simplify media → placeholders
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ def _simplify_media(parser: LexborHTMLParser) -> None:
213
+ """Replace img/video/audio/source/track with text placeholders.
214
+
215
+ img → [img: <filename|size> <alt_first_10_chars>]
216
+ video → [video]
217
+ audio/source/track → [audio]
218
+ """
219
+ for node in _walk_nodes(parser.root):
220
+ if not node.is_element_node:
221
+ continue
222
+ tag = node.tag.lower()
223
+ if tag == "img":
224
+ attrs = node.attributes
225
+ alt = (attrs.get("alt") or "").strip()
226
+ src = attrs.get("src", "")
227
+ if src.startswith("data:"):
228
+ # base64: show human-readable size
229
+ # data:image/png;base64,XXXX → estimate decoded size
230
+ _, _, data = src.partition(",")
231
+ size_str = _human_size(int(len(data) * 3 / 4)) if data else "0B"
232
+ identity = size_str
233
+ else:
234
+ identity = _normalize_url(src) if src else "unknown"
235
+ alt_text = alt[: settings.MEDIA_ALT_HEAD] if alt else ""
236
+ if alt_text:
237
+ node.replace_with(f"[img: {identity} {alt_text}]")
238
+ else:
239
+ node.replace_with(f"[img: {identity}]")
240
+ elif tag in ("video", "audio", "source", "track"):
241
+ node.replace_with("[video]" if tag == "video" else "[audio]")
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Step 3: Clean attributes + URL normalization
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ def _is_data_attr(attr: str) -> bool:
250
+ """Check if an attribute is a data-* attribute."""
251
+ return attr.startswith("data-")
252
+
253
+
254
+ def _attrs_to_remove(attrs: Any) -> list[str]:
255
+ """Compute which attribute names to delete based on mode and settings.
256
+
257
+ Blacklist mode (default): delete only REMOVE_ATTRS entries.
258
+ Whitelist mode: delete everything except KEEP_ATTRS + data-* (if enabled).
259
+ """
260
+ if settings.ATTR_MODE == "whitelist":
261
+ return [
262
+ k
263
+ for k in attrs
264
+ if k.lower() not in settings.KEEP_ATTRS
265
+ and not (_is_data_attr(k) and settings.KEEP_DATA_ATTRS)
266
+ ]
267
+ # Blacklist mode: only remove listed attrs
268
+ return [k for k in attrs if k.lower() in settings.REMOVE_ATTRS]
269
+
270
+
271
+ def _clean_attributes_dom(parser: LexborHTMLParser) -> None:
272
+ """Clean attributes using blacklist/whitelist mode.
273
+
274
+ Special handling for <a> tags:
275
+ - href empty → remove
276
+ - href="#" → unwrap if text content, else remove
277
+ - normal href → keep href (+ data-* attrs), remove others
278
+
279
+ For all other elements:
280
+ - blacklist mode: remove REMOVE_ATTRS, keep data-* if enabled
281
+ - whitelist mode: keep only KEEP_ATTRS + data-*
282
+
283
+ NOTE: Must use node.attrs (LexborAttributes) not node.attributes (copy dict).
284
+ """
285
+ to_remove: list[Any] = []
286
+ to_unwrap: list[Any] = []
287
+ for node in _walk_nodes(parser.root):
288
+ if not node.is_element_node:
289
+ continue
290
+ tag = node.tag.lower()
291
+ # Use node.attrs (LexborAttributes) for in-place DOM modification
292
+ node_attrs = node.attrs
293
+ attrs_dict = node.attributes # copy, for reading values
294
+
295
+ if tag == "a":
296
+ href = attrs_dict.get("href", "")
297
+ if not href:
298
+ to_remove.append(node)
299
+ continue
300
+ if href.strip() == "#":
301
+ has_text = False
302
+ child = node.first_child
303
+ while child is not None:
304
+ if child.is_text_node:
305
+ t = child.text_lexbor()
306
+ if t and t.strip():
307
+ has_text = True
308
+ break
309
+ child = child.next
310
+ if has_text:
311
+ to_unwrap.append(node)
312
+ else:
313
+ to_remove.append(node)
314
+ continue
315
+ # Normal anchor: keep href + data-*, remove others
316
+ keys_to_del = [
317
+ k
318
+ for k in attrs_dict
319
+ if k.lower() != "href"
320
+ and not (_is_data_attr(k) and settings.KEEP_DATA_ATTRS)
321
+ ]
322
+ for k in keys_to_del:
323
+ del node_attrs[k]
324
+ # Normalize href via attrs proxy
325
+ node_attrs["href"] = _normalize_url(href)
326
+ elif tag == "script":
327
+ # Script: keep src + type + data-*, normalize src
328
+ keys_to_del = [
329
+ k
330
+ for k in attrs_dict
331
+ if k.lower() not in ("src", "type")
332
+ and not (_is_data_attr(k) and settings.KEEP_DATA_ATTRS)
333
+ ]
334
+ for k in keys_to_del:
335
+ del node_attrs[k]
336
+ if "src" in attrs_dict:
337
+ node_attrs["src"] = _normalize_url(attrs_dict["src"])
338
+ else:
339
+ # General elements: apply attribute mode
340
+ keys_to_del = _attrs_to_remove(attrs_dict)
341
+ for k in keys_to_del:
342
+ del node_attrs[k]
343
+ for node in to_remove:
344
+ node.remove()
345
+ for node in to_unwrap:
346
+ node.unwrap()
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # Step 6: Unwrap bare wrapper tags
351
+ # ---------------------------------------------------------------------------
352
+
353
+ _MAX_UNWRAP_ROUNDS = 100
354
+
355
+
356
+ def _is_bare(node: Any) -> bool:
357
+ """Check if node has no meaningful attributes after filtering."""
358
+ if not node.attributes:
359
+ return True
360
+ # Has attributes only if something remains after attribute cleaning
361
+ return False
362
+
363
+
364
+ def _has_structure_descendant(node: Any) -> bool:
365
+ """Check if direct children contain structural tags (ul/ol/table/tr/td etc.)."""
366
+ child = node.first_child
367
+ while child is not None:
368
+ if not child.is_text_node and child.tag.lower() in STRUCTURE_TAGS:
369
+ return True
370
+ child = child.next
371
+ return False
372
+
373
+
374
+ def _unwrap_bare_tags(parser: LexborHTMLParser) -> None:
375
+ """Remove bare wrapper tags (no attributes), keeping children.
376
+
377
+ Protection rules:
378
+ - KEEP_TAGS never unwrapped
379
+ - Parent of structural tags never unwrapped
380
+ """
381
+ for _ in range(_MAX_UNWRAP_ROUNDS):
382
+ to_unwrap: list[Any] = []
383
+ for node in _walk_nodes(parser.root):
384
+ if not node.is_element_node:
385
+ continue
386
+ tag = node.tag.lower()
387
+ if tag in KEEP_TAGS:
388
+ continue
389
+ if not _is_bare(node):
390
+ continue
391
+ if _has_structure_descendant(node):
392
+ continue
393
+ to_unwrap.append(node)
394
+ if not to_unwrap:
395
+ break
396
+ # Process leaf-upward to avoid parent-child conflicts
397
+ for node in reversed(to_unwrap):
398
+ if node.first_child is None:
399
+ node.remove()
400
+ else:
401
+ node.unwrap()
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # Step 5: Truncate text nodes (regular + script inline)
406
+ # ---------------------------------------------------------------------------
407
+
408
+
409
+ def shorten_text(text: str, head: int, tail: int) -> str:
410
+ """Truncate text by keeping head and tail units, inserting a placeholder.
411
+
412
+ Units: Chinese chars, English words, date/time patterns, numbers.
413
+ """
414
+ if head <= 0 and tail <= 0:
415
+ return text
416
+ matches = list(_UNIT_RE.finditer(text))
417
+ total = len(matches)
418
+ if total <= head + tail:
419
+ return text
420
+ head_end = matches[head - 1].end() if head > 0 else 0
421
+ tail_start = matches[-tail].start() if tail > 0 else len(text)
422
+ hidden = total - head - tail
423
+ if hidden <= 6:
424
+ return text
425
+ return text[:head_end] + _SHORTENED_PLACEHOLDER.format(n=hidden) + text[tail_start:]
426
+
427
+
428
+ def _truncate_text_nodes(parser: LexborHTMLParser) -> None:
429
+ """Truncate all text nodes and script inline code."""
430
+ ops: list[tuple[Any, str | None]] = []
431
+ for node in _walk_nodes(parser.root):
432
+ if not node.is_text_node:
433
+ continue
434
+ parent = node.parent
435
+ if parent is None or parent.is_text_node:
436
+ continue
437
+ parent_tag = parent.tag.lower() if parent.is_element_node else ""
438
+ # Skip title tag text
439
+ if parent_tag == "title":
440
+ continue
441
+ original = node.text_lexbor()
442
+ if not original.strip():
443
+ ops.append((node, None))
444
+ continue
445
+ cleaned = _MULTI_WS_RE.sub(" ", original).strip()
446
+ # Script inline code uses SCRIPT_HEAD/TAIL, others use TEXT_HEAD/TAIL
447
+ if parent_tag == "script":
448
+ shortened = shorten_text(
449
+ cleaned, settings.SCRIPT_HEAD, settings.SCRIPT_TAIL
450
+ )
451
+ else:
452
+ shortened = shorten_text(cleaned, settings.TEXT_HEAD, settings.TEXT_TAIL)
453
+ if shortened != original:
454
+ ops.append((node, shortened))
455
+ for node, new_text in ops:
456
+ if new_text is None:
457
+ node.remove()
458
+ else:
459
+ node.replace_with(new_text)
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # Step 4: Truncate repeated list items
464
+ # ---------------------------------------------------------------------------
465
+
466
+
467
+ def _truncate_repeated_items(parser: LexborHTMLParser) -> None:
468
+ """Truncate consecutive li/tr items exceeding threshold, keep head+tail, insert marker."""
469
+ containers: list[list[Any]] = []
470
+ for node in _walk_nodes(parser.root):
471
+ if not node.is_element_node:
472
+ continue
473
+ if node.tag.lower() not in ("ul", "ol", "table", "tbody", "thead", "tfoot"):
474
+ continue
475
+ items: list[Any] = []
476
+ child = node.first_child
477
+ while child is not None:
478
+ if child.is_element_node and child.tag.lower() in TRUNCATABLE_ITEM_TAGS:
479
+ items.append(child)
480
+ child = child.next
481
+ threshold = (
482
+ settings.LIST_TRUNCATE_KEEP_HEAD + settings.LIST_TRUNCATE_KEEP_TAIL + 1
483
+ )
484
+ if len(items) > threshold:
485
+ containers.append(items)
486
+ for items in containers:
487
+ total = len(items)
488
+ keep_head = settings.LIST_TRUNCATE_KEEP_HEAD
489
+ keep_tail = settings.LIST_TRUNCATE_KEEP_TAIL
490
+ to_remove = items[keep_head:-keep_tail] if keep_tail > 0 else items[keep_head:]
491
+ omitted = len(to_remove)
492
+ # Insert marker before first tail item
493
+ anchor = items[-keep_tail] if keep_tail > 0 else items[-1]
494
+ anchor.insert_before(f"[共{total}条,省略{omitted}条]")
495
+ for item in to_remove:
496
+ item.remove()
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Step 7: Whitespace normalization
501
+ # ---------------------------------------------------------------------------
502
+
503
+
504
+ def _clean_whitespace(html: str) -> str:
505
+ """Collapse multi-char whitespace between and within tags."""
506
+ return re.sub(r">\s+<", "> <", html)
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # Pipeline assembly
511
+ # ---------------------------------------------------------------------------
512
+
513
+ _PIPELINE_STEPS = [
514
+ ("strip_noise", _strip_noise),
515
+ ("simplify_media", _simplify_media),
516
+ ("clean_attributes", _clean_attributes_dom),
517
+ ("truncate_lists", _truncate_repeated_items),
518
+ ("truncate_text", _truncate_text_nodes),
519
+ ("unwrap_bare_tags", _unwrap_bare_tags),
520
+ ]
521
+
522
+
523
+ def simplify_html(html: str) -> dict[str, Any]:
524
+ """Run the 7-step simplification pipeline.
525
+
526
+ Returns:
527
+ dict with keys:
528
+ - "html": simplified HTML string
529
+ - "stats": dict[str, dict[str, int]] mapping step name to
530
+ {char_before, char_after}
531
+ """
532
+ stats: dict[str, dict[str, int]] = {}
533
+ parser = LexborHTMLParser(html)
534
+
535
+ for step_name, step_fn in _PIPELINE_STEPS:
536
+ char_before = len(parser.html)
537
+ step_fn(parser)
538
+ char_after = len(parser.html)
539
+ stats[step_name] = {"char_before": char_before, "char_after": char_after}
540
+
541
+ # Final whitespace cleanup (string-level)
542
+ ws_before = len(parser.html)
543
+ result_html = _clean_whitespace(parser.html)
544
+ ws_after = len(result_html)
545
+ stats["clean_whitespace"] = {"char_before": ws_before, "char_after": ws_after}
546
+
547
+ return {"html": result_html, "stats": stats}
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: html-reader-llm
3
+ Version: 0.0.1
4
+ Summary: HTML simplification and intelligent extraction for LLM
5
+ Author-email: Clericpy <clericpy@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: minify-html>=0.18.1
10
+ Requires-Dist: python-dotenv>=1.2.2
11
+ Requires-Dist: selectolax>=0.4.7
12
+ Requires-Dist: trafilatura>=2.2.0
13
+
14
+ # html-reader-llm
15
+
16
+ HTML simplification and intelligent extraction for LLM.
17
+
18
+ ## Features
19
+
20
+ - HTML simplification for LLM consumption
21
+ - Render detection (HTTP vs Chrome)
22
+ - LLM-powered page classification and CSS selector generation
23
+ - CSS selector validation
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install html-reader-llm
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Simplify HTML
35
+ html-reader-llm simplify < page.html
36
+
37
+ # Detect if browser rendering is needed
38
+ html-reader-llm detect https://example.com
39
+
40
+ # Full analysis with LLM
41
+ html-reader-llm analyze https://example.com
42
+
43
+ # Fetch HTML
44
+ html-reader-llm fetch https://example.com
45
+
46
+ # Validate CSS selectors
47
+ html-reader-llm validate https://example.com --rules-file selectors.json
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,13 @@
1
+ html_reader_llm/__init__.py,sha256=nvxvpTlcArqeCGCqqTa9571dAJSvJtGHEaz2md3rqd4,1210
2
+ html_reader_llm/browser.py,sha256=Kw7d70YTyL0m6vK6slO4icYYu5iJqVYcYndmLA4_NoE,4717
3
+ html_reader_llm/cli.py,sha256=oOQJU3wrOTZ0OQUMypchiBa03k-0A5_QuuqAHfGudA0,19957
4
+ html_reader_llm/llm.py,sha256=vWD6ZvNBSzkjVF6SaKOQOfYVc4lozIPDKgTJTCqENOY,15824
5
+ html_reader_llm/render_detect.py,sha256=XTPkg8jsB9py31LkJ9_H2pi1BxjCpPTVY4Q8QL3fRwE,12607
6
+ html_reader_llm/selector_validator.py,sha256=sZsEW9Rfx_ei-xZOqmoY4miojehjk-DHfbex5U52n94,5495
7
+ html_reader_llm/settings.py,sha256=jV3jS-u1wtgSirLDqI_DVraet12Rqgt0LoKtOzJcLtE,2525
8
+ html_reader_llm/simplify.py,sha256=XuP3KP_JeXvvsigWo9WoGLj2do7GLLKB7F3TXNIvzy8,17345
9
+ html_reader_llm-0.0.1.dist-info/entry_points.txt,sha256=eA-OAGTqIjfT4ATIHu5dSj6aJn6Bb3cfyUL1PR7gSEo,60
10
+ html_reader_llm-0.0.1.dist-info/licenses/LICENSE,sha256=2wBhZrpJjfa6xenTNHJNPMLlhljJ3R6p8zhA3SGNTfo,1086
11
+ html_reader_llm-0.0.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
12
+ html_reader_llm-0.0.1.dist-info/METADATA,sha256=1Ql68mmnTjL1ZMbm8Z6ivM6LnSqp-VMpLJfRzpRx97E,1107
13
+ html_reader_llm-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ html-reader-llm=html_reader_llm.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ClericPy
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.