pafer 0.9.7__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.
- pafer/__init__.py +26 -0
- pafer/cache.py +50 -0
- pafer/cli.py +52 -0
- pafer/config.py +115 -0
- pafer/exceptions.py +18 -0
- pafer/exporters.py +23 -0
- pafer/feed.py +23 -0
- pafer/pafer.py +126 -0
- pafer/utils.py +29 -0
- pafer-0.9.7.dist-info/METADATA +142 -0
- pafer-0.9.7.dist-info/RECORD +14 -0
- pafer-0.9.7.dist-info/WHEEL +5 -0
- pafer-0.9.7.dist-info/licenses/LICENSE +21 -0
- pafer-0.9.7.dist-info/top_level.txt +1 -0
pafer/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
from .pafer import HTMLParser
|
|
3
|
+
from .config import Config, DEFAULT_CONFIG, load_config, save_config
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
HTMLParserError,
|
|
6
|
+
SanitizationError,
|
|
7
|
+
SelectorSyntaxError,
|
|
8
|
+
ConfigLoadError
|
|
9
|
+
)
|
|
10
|
+
from .utils import minify_html, is_probably_html
|
|
11
|
+
|
|
12
|
+
from . import cache, feed, exporters
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'HTMLParser',
|
|
16
|
+
'Config',
|
|
17
|
+
'DEFAULT_CONFIG',
|
|
18
|
+
'load_config',
|
|
19
|
+
'save_config',
|
|
20
|
+
'minify_html',
|
|
21
|
+
'is_probably_html',
|
|
22
|
+
'cache',
|
|
23
|
+
'feed',
|
|
24
|
+
'exporters',
|
|
25
|
+
'HTMLParserError'
|
|
26
|
+
]
|
pafer/cache.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory caching system for parsed documents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from typing import Any, Dict, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LRUCache:
|
|
11
|
+
"""In-memory LRU cache with TTL support for parsed documents."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, max_size: int = 100, ttl_seconds: int = 300):
|
|
14
|
+
self.max_size = max_size
|
|
15
|
+
self.ttl = ttl_seconds
|
|
16
|
+
self._cache: Dict[str, Tuple[Any, float]] = OrderedDict()
|
|
17
|
+
|
|
18
|
+
def get(self, key: str) -> Optional[Any]:
|
|
19
|
+
entry = self._cache.get(key)
|
|
20
|
+
if entry is None:
|
|
21
|
+
return None
|
|
22
|
+
value, timestamp = entry
|
|
23
|
+
if time.time() - timestamp >= self.ttl:
|
|
24
|
+
del self._cache[key]
|
|
25
|
+
return None
|
|
26
|
+
self._cache.move_to_end(key)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
def set(self, key: str, value: Any) -> None:
|
|
30
|
+
self._purge_expired()
|
|
31
|
+
if key in self._cache:
|
|
32
|
+
self._cache.move_to_end(key)
|
|
33
|
+
self._cache[key] = (value, time.time())
|
|
34
|
+
while len(self._cache) > self.max_size:
|
|
35
|
+
self._cache.popitem(last=False)
|
|
36
|
+
|
|
37
|
+
def _purge_expired(self) -> None:
|
|
38
|
+
now = time.time()
|
|
39
|
+
expired = [
|
|
40
|
+
key for key, (_, timestamp) in self._cache.items()
|
|
41
|
+
if now - timestamp >= self.ttl
|
|
42
|
+
]
|
|
43
|
+
for key in expired:
|
|
44
|
+
del self._cache[key]
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
self._cache.clear()
|
|
48
|
+
|
|
49
|
+
def __len__(self) -> int:
|
|
50
|
+
return len(self._cache)
|
pafer/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line interface for HTMLParser.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .pafer import HTMLParser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_cli():
|
|
12
|
+
pafer_cli = argparse.ArgumentParser(
|
|
13
|
+
description='HTMLParser - Enterprise HTML Processing Tool',
|
|
14
|
+
epilog='Example: htmlpafer parse -f input.html --minify'
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
subpafers = pafer_cli.add_subpafers(dest='command', help='Available commands')
|
|
18
|
+
|
|
19
|
+
parse_cmd = subpafers.add_pafer('parse', help='Parse an HTML file')
|
|
20
|
+
parse_cmd.add_argument('-f', '--file', required=True, help='Path to HTML file')
|
|
21
|
+
parse_cmd.add_argument('--minify', action='store_true', help='Minify output')
|
|
22
|
+
|
|
23
|
+
sanit_cmd = subpafers.add_pafer('sanitize', help='Sanitize an HTML file')
|
|
24
|
+
sanit_cmd.add_argument('-f', '--file', required=True, help='Path to HTML file')
|
|
25
|
+
|
|
26
|
+
args = pafer_cli.parse_args()
|
|
27
|
+
|
|
28
|
+
if args.command == 'parse':
|
|
29
|
+
try:
|
|
30
|
+
with open(args.file, 'r') as f:
|
|
31
|
+
html = f.read()
|
|
32
|
+
|
|
33
|
+
p = HTMLParser()
|
|
34
|
+
if args.minify:
|
|
35
|
+
html = p.minify(html)
|
|
36
|
+
|
|
37
|
+
result = p.parse(html)
|
|
38
|
+
print(f"[+] Parsed {len(result['tags'])} tags successfully.")
|
|
39
|
+
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
print(f"[-] Error: File '{args.file}' not found.")
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
elif args.command == 'sanitize':
|
|
45
|
+
print('[+] Sanitization not implemented in CLI yet.')
|
|
46
|
+
|
|
47
|
+
else:
|
|
48
|
+
pafer_cli.print_help()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == '__main__':
|
|
52
|
+
run_cli()
|
pafer/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loading and persistence for the pafer package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pickle
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .exceptions import ConfigLoadError
|
|
12
|
+
|
|
13
|
+
PathLike = Union[str, os.PathLike]
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
16
|
+
'parse_tables': True,
|
|
17
|
+
'strip_whitespace': True,
|
|
18
|
+
'encoding': 'utf-8',
|
|
19
|
+
'max_depth': 100,
|
|
20
|
+
'default_tag': 'div',
|
|
21
|
+
'preserve_comments': False,
|
|
22
|
+
'strict_mode': False,
|
|
23
|
+
'remove_comments': True,
|
|
24
|
+
'allowed_tags': {'p', 'br', 'b', 'i', 'a', 'ul', 'ol', 'li', 'div', 'span'},
|
|
25
|
+
'max_file_size_mb': 10,
|
|
26
|
+
'cache_size': 50
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def _sanitize_value(value: Any) -> Any:
|
|
30
|
+
"""Recursively verify that a value only contains allowed plain types."""
|
|
31
|
+
if isinstance(value, (str, bytes, int, float, bool, type(None))):
|
|
32
|
+
return value
|
|
33
|
+
if isinstance(value, dict):
|
|
34
|
+
return {str(key): _sanitize_value(item) for key, item in value.items()}
|
|
35
|
+
if isinstance(value, (list, tuple)):
|
|
36
|
+
return [_sanitize_value(item) for item in value]
|
|
37
|
+
if isinstance(value, (set, frozenset)):
|
|
38
|
+
return {_sanitize_value(item) for item in value}
|
|
39
|
+
raise ConfigLoadError(
|
|
40
|
+
f'disallowed type {type(value).__name__!r} in config file; '
|
|
41
|
+
'only plain data types are allowed'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Config:
|
|
46
|
+
"""Configuration holder that layers user settings over DEFAULT_CONFIG."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, settings: Optional[Dict[str, Any]] = None):
|
|
49
|
+
self._settings: Dict[str, Any] = {**DEFAULT_CONFIG, **(settings or {})}
|
|
50
|
+
|
|
51
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
52
|
+
return self._settings.get(key, default)
|
|
53
|
+
|
|
54
|
+
def update(self, settings: Dict[str, Any]) -> 'Config':
|
|
55
|
+
"""Return a new Config with extra settings layered on top of this one."""
|
|
56
|
+
return Config({**self._settings, **(settings or {})})
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
59
|
+
return dict(self._settings)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def load_default(cls) -> 'Config':
|
|
63
|
+
"""Return a Config built from DEFAULT_CONFIG without reading any file."""
|
|
64
|
+
return cls()
|
|
65
|
+
|
|
66
|
+
def __eq__(self, other: Any) -> bool:
|
|
67
|
+
if isinstance(other, Config):
|
|
68
|
+
return self._settings == other._settings
|
|
69
|
+
return NotImplemented
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return f'Config({self._settings})'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_pkl(path: PathLike) -> Dict[str, Any]:
|
|
76
|
+
"""Read a pickled dict from path."""
|
|
77
|
+
try:
|
|
78
|
+
with open(path, 'rb') as handle:
|
|
79
|
+
data = pickle.load(handle)
|
|
80
|
+
except ConfigLoadError:
|
|
81
|
+
raise
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise ConfigLoadError(f"failed to read config file '{path}': {exc}") from exc
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise ConfigLoadError(
|
|
86
|
+
f"config file '{path}' must contain a dict, "
|
|
87
|
+
f"got {type(data).__name__}"
|
|
88
|
+
)
|
|
89
|
+
return _sanitize_value(data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_config(path: PathLike) -> Config:
|
|
93
|
+
"""Load a Config from a caller-supplied pickle file."""
|
|
94
|
+
return Config(_read_pkl(path))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_config(config: Config, path: PathLike) -> Path:
|
|
98
|
+
"""Persist a Config to path atomically."""
|
|
99
|
+
settings = config.to_dict()
|
|
100
|
+
target = Path(path)
|
|
101
|
+
if not target.parent.exists():
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), suffix='.pkl.tmp')
|
|
105
|
+
try:
|
|
106
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
107
|
+
pickle.dump(settings, handle, protocol=pickle.HIGHEST_PROTOCOL)
|
|
108
|
+
os.replace(tmp_name, str(target))
|
|
109
|
+
except Exception:
|
|
110
|
+
try:
|
|
111
|
+
os.unlink(tmp_name)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
raise
|
|
115
|
+
return target
|
pafer/exceptions.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for HTMLParser.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
class HTMLParserError(Exception):
|
|
6
|
+
"""Base exception for all HTMLParser errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SanitizationError(HTMLParserError):
|
|
10
|
+
"""Raised when HTML sanitization fails or encounters malicious patterns."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SelectorSyntaxError(HTMLParserError):
|
|
14
|
+
"""Raised when a CSS selector is invalid."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigLoadError(HTMLParserError):
|
|
18
|
+
"""Raised when configuration cannot be loaded."""
|
pafer/exporters.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export parsed HTML data to standard formats.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataExporter:
|
|
12
|
+
"""Export parsed HTML data to standard formats."""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def to_csv(rows: List[List[str]]) -> str:
|
|
16
|
+
output = io.StringIO()
|
|
17
|
+
writer = csv.writer(output)
|
|
18
|
+
writer.writerows(rows)
|
|
19
|
+
return output.getvalue()
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def to_json(data: Dict[str, Any], indent: int = 2) -> str:
|
|
23
|
+
return json.dumps(data, indent=indent, default=str)
|
pafer/feed.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RSS feed item extraction.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FeedParser:
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.item_pattern = re.compile(r'<item.*?>(.*?)</item>', re.DOTALL)
|
|
13
|
+
self.tag_pattern = re.compile(r'<(title|link|description|pubDate)>(.*?)</\1>', re.DOTALL)
|
|
14
|
+
|
|
15
|
+
def parse_rss(self, xml_content: str) -> List[Dict]:
|
|
16
|
+
"""Extract <item> entries as dicts of their child tags."""
|
|
17
|
+
items = []
|
|
18
|
+
for match in self.item_pattern.finditer(xml_content):
|
|
19
|
+
tags = dict(self.tag_pattern.findall(match.group(1)))
|
|
20
|
+
if 'description' in tags:
|
|
21
|
+
tags['description'] = re.sub(r'<[^>]+>', '', tags['description'])
|
|
22
|
+
items.append(tags)
|
|
23
|
+
return items
|
pafer/pafer.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML Parser implementation with sanitization and selectors.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Dict, Optional, Set
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .utils import minify_html, extract_doctype
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HTMLParser:
|
|
13
|
+
"""Parse, extract, and sanitize HTML content."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Optional[Config] = None, use_cache: bool = False):
|
|
16
|
+
self.config = config or Config()
|
|
17
|
+
self._tag_pattern = re.compile(r'<(\w+)([^>]*)>(.*?)</\1>', re.DOTALL)
|
|
18
|
+
self._attr_pattern = re.compile(r'(\w+)=["\']([^"\']*)["\']')
|
|
19
|
+
|
|
20
|
+
self.cache = None
|
|
21
|
+
if use_cache:
|
|
22
|
+
from .cache import LRUCache
|
|
23
|
+
self.cache = LRUCache(max_size=self.config.get('cache_size', 50))
|
|
24
|
+
|
|
25
|
+
def parse(self, html: str) -> Dict:
|
|
26
|
+
"""Parse HTML and return structured data."""
|
|
27
|
+
if self.cache:
|
|
28
|
+
cached = self.cache.get(html)
|
|
29
|
+
if cached:
|
|
30
|
+
return cached
|
|
31
|
+
|
|
32
|
+
result = {
|
|
33
|
+
'tags': [],
|
|
34
|
+
'text': [],
|
|
35
|
+
'metadata': {},
|
|
36
|
+
'doctype': extract_doctype(html)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if self.config.get('strip_whitespace', True):
|
|
40
|
+
html = re.sub(r'\s+', ' ', html).strip()
|
|
41
|
+
|
|
42
|
+
for match in self._tag_pattern.finditer(html):
|
|
43
|
+
tag_name, attrs_str, content = match.groups()
|
|
44
|
+
attrs = dict(self._attr_pattern.findall(attrs_str))
|
|
45
|
+
|
|
46
|
+
tag_data = {
|
|
47
|
+
'name': tag_name,
|
|
48
|
+
'attributes': attrs,
|
|
49
|
+
'content': content.strip() if self.config.get('strip_whitespace') else content
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if self.config.get('parse_tables', True) and tag_name == 'table':
|
|
53
|
+
tag_data['rows'] = self._parse_table(content)
|
|
54
|
+
|
|
55
|
+
result['tags'].append(tag_data)
|
|
56
|
+
|
|
57
|
+
title_match = re.search(r'<title>(.*?)</title>', html, re.IGNORECASE)
|
|
58
|
+
if title_match:
|
|
59
|
+
result['metadata']['title'] = title_match.group(1)
|
|
60
|
+
|
|
61
|
+
if self.cache:
|
|
62
|
+
self.cache.set(html, result)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
def select(self, html: str, selector: str) -> List[Dict]:
|
|
66
|
+
"""Basic CSS selector simulation (ID, class and tag selectors)."""
|
|
67
|
+
if selector.startswith('#'):
|
|
68
|
+
attr_search = f'id="{selector[1:]}"'
|
|
69
|
+
elif selector.startswith('.'):
|
|
70
|
+
attr_search = f'class="{selector[1:]}"'
|
|
71
|
+
else:
|
|
72
|
+
attr_search = f'<{selector} '
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
for match in self._tag_pattern.finditer(html):
|
|
76
|
+
tag_name, attrs_str, content = match.groups()
|
|
77
|
+
if attr_search in attrs_str or attr_search in f'<{tag_name} ':
|
|
78
|
+
results.append({
|
|
79
|
+
'tag': tag_name,
|
|
80
|
+
'attrs': dict(self._attr_pattern.findall(attrs_str)),
|
|
81
|
+
'inner_html': content
|
|
82
|
+
})
|
|
83
|
+
return results
|
|
84
|
+
|
|
85
|
+
def sanitize(self, html: str, allowed_tags: Optional[Set[str]] = None) -> str:
|
|
86
|
+
"""Strip out tags that aren't in the allowed list."""
|
|
87
|
+
if allowed_tags is None:
|
|
88
|
+
allowed_tags = self.config.get('allowed_tags') or {
|
|
89
|
+
'p', 'br', 'b', 'i', 'a', 'ul', 'ol', 'li', 'div', 'span'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def replace_tag(match):
|
|
93
|
+
tag = match.group(1)
|
|
94
|
+
return match.group(0) if tag.lower() in allowed_tags else ''
|
|
95
|
+
|
|
96
|
+
cleaned = re.sub(r'</(\w+)>', replace_tag, html, flags=re.IGNORECASE)
|
|
97
|
+
cleaned = re.sub(r'<(\w+)([^>]*)>', replace_tag, cleaned, flags=re.IGNORECASE)
|
|
98
|
+
return cleaned
|
|
99
|
+
|
|
100
|
+
def minify(self, html: str) -> str:
|
|
101
|
+
"""Minify HTML based on config settings."""
|
|
102
|
+
return minify_html(html, remove_comments=self.config.get('remove_comments', True))
|
|
103
|
+
|
|
104
|
+
def _parse_table(self, table_html: str) -> List[List[str]]:
|
|
105
|
+
rows = []
|
|
106
|
+
row_pattern = re.compile(r'<tr[^>]*>(.*?)</tr>', re.DOTALL)
|
|
107
|
+
cell_pattern = re.compile(r'<t[dh][^>]*>(.*?)</t[dh]>', re.DOTALL)
|
|
108
|
+
for row_match in row_pattern.finditer(table_html):
|
|
109
|
+
cells = [c.strip() for c in cell_pattern.findall(row_match.group(1))]
|
|
110
|
+
if cells:
|
|
111
|
+
rows.append(cells)
|
|
112
|
+
return rows
|
|
113
|
+
|
|
114
|
+
def extract_links(self, html: str) -> List[Dict]:
|
|
115
|
+
"""Extract all links from HTML."""
|
|
116
|
+
links = []
|
|
117
|
+
link_pattern = re.compile(r'<a\s+([^>]*)>(.*?)</a>', re.DOTALL)
|
|
118
|
+
for match in link_pattern.finditer(html):
|
|
119
|
+
attrs = dict(self._attr_pattern.findall(match.group(1)))
|
|
120
|
+
if 'href' in attrs:
|
|
121
|
+
links.append({
|
|
122
|
+
'url': attrs['href'],
|
|
123
|
+
'text': match.group(2).strip(),
|
|
124
|
+
'attributes': {k: v for k, v in attrs.items() if k != 'href'}
|
|
125
|
+
})
|
|
126
|
+
return links
|
pafer/utils.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for HTML processing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def minify_html(html: str, remove_comments: bool = True) -> str:
|
|
10
|
+
"""Minify HTML by removing unnecessary whitespace and optional comments."""
|
|
11
|
+
if remove_comments:
|
|
12
|
+
html = re.sub(r'<!--(.*?)-->', '', html, flags=re.DOTALL)
|
|
13
|
+
|
|
14
|
+
html = re.sub(r'[ \t]+', ' ', html)
|
|
15
|
+
html = re.sub(r'\s+<(div|p|h[1-6]|br|hr|ul|ol|li|table|tr|td|th|head|body|html)', r'<\1', html)
|
|
16
|
+
html = re.sub(r'</(div|p|h[1-6]|ul|ol|li|table|tr|td|th|head|body|html)>\s+', r'</\1>', html)
|
|
17
|
+
return html.strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_doctype(html: str) -> Optional[str]:
|
|
21
|
+
"""Extract the DOCTYPE declaration from HTML."""
|
|
22
|
+
match = re.search(r'<!DOCTYPE\s+([^>]+)>', html, re.IGNORECASE)
|
|
23
|
+
return match.group(0) if match else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_probably_html(content: str) -> bool:
|
|
27
|
+
"""Heuristic check to determine if string is HTML."""
|
|
28
|
+
html_indicators = [r'<html', r'<head', r'<body', r'<div', r'<!DOCTYPE']
|
|
29
|
+
return any(re.search(pattern, content, re.IGNORECASE) for pattern in html_indicators)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pafer
|
|
3
|
+
Version: 0.9.7
|
|
4
|
+
Summary: Regex-based HTML parsing.
|
|
5
|
+
Author-email: werson <werson.tech@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- Parse HTML into structured tag / text / metadata results
|
|
34
|
+
- CSS-style selectors (`#id`, `.class`, tag name)
|
|
35
|
+
- Whitelist-based sanitization
|
|
36
|
+
- Minification with comment stripping
|
|
37
|
+
- Link and table extraction
|
|
38
|
+
- LRU result caching with TTL
|
|
39
|
+
- RSS 2.0 feed parsing
|
|
40
|
+
- JSON / CSV export
|
|
41
|
+
- CLI tool
|
|
42
|
+
- Standard library only, no third-party dependencies
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install pafer
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pafer import HTMLParser
|
|
54
|
+
|
|
55
|
+
html = '<div class="post"><h1>Title</h1><a href="/about">About</a></div>'
|
|
56
|
+
|
|
57
|
+
p = HTMLParser()
|
|
58
|
+
result = p.parse(html)
|
|
59
|
+
|
|
60
|
+
result['tags'] # list of {name, attributes, content, rows?}
|
|
61
|
+
result['metadata'] # {'title': ...}
|
|
62
|
+
result['doctype'] # '<!DOCTYPE ...>' or None
|
|
63
|
+
|
|
64
|
+
p.select(html, '.post') # match by #id, .class or tag name
|
|
65
|
+
p.sanitize(html) # keep only allowed tags
|
|
66
|
+
p.minify(html) # collapse whitespace, strip comments
|
|
67
|
+
p.extract_links(html) # [{'url': ..., 'text': ..., 'attributes': ...}]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Enable the parsed-result cache with `HTMLParser(use_cache=True)`.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pafer import Config, load_config, save_config
|
|
76
|
+
|
|
77
|
+
cfg = Config({'strip_whitespace': False, 'cache_size': 100})
|
|
78
|
+
save_config(cfg, 'my.pkl')
|
|
79
|
+
cfg = load_config('my.pkl')
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Default settings
|
|
83
|
+
|
|
84
|
+
| Key | Default | Purpose |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `parse_tables` | `True` | extract table rows while parsing |
|
|
87
|
+
| `strip_whitespace` | `True` | collapse whitespace |
|
|
88
|
+
| `encoding` | `utf-8` | decode bytes input |
|
|
89
|
+
| `max_depth` | `100` | maximum element nesting |
|
|
90
|
+
| `default_tag` | `div` | fallback tag |
|
|
91
|
+
| `preserve_comments` | `False` | keep comments in the tree |
|
|
92
|
+
| `strict_mode` | `False` | raise on malformed HTML |
|
|
93
|
+
| `remove_comments` | `True` | strip comments when minifying |
|
|
94
|
+
| `allowed_tags` | `p, br, b, i, a, ul, ol, li, div, span` | sanitize whitelist |
|
|
95
|
+
| `max_file_size_mb` | `10` | reject oversized input |
|
|
96
|
+
| `cache_size` | `50` | cache capacity |
|
|
97
|
+
|
|
98
|
+
## Caching
|
|
99
|
+
|
|
100
|
+
`pafer.cache.LRUCache` is an in-memory LRU cache with TTL expiry:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from pafer.cache import LRUCache
|
|
104
|
+
|
|
105
|
+
cache = LRUCache(max_size=100, ttl_seconds=300)
|
|
106
|
+
cache.set('key', value)
|
|
107
|
+
cache.get('key')
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## RSS feeds
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pafer.feed import FeedParser
|
|
114
|
+
|
|
115
|
+
items = FeedParser().parse_rss(xml_content)
|
|
116
|
+
items[0]['title'] # 'title', 'link', 'description', 'pubDate'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Exporting
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from pafer.exporters import DataExporter
|
|
123
|
+
|
|
124
|
+
DataExporter.to_json(data) # JSON string with 2-space indent
|
|
125
|
+
DataExporter.to_csv(rows) # CSV from a list of rows
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## CLI
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
python -m pafer.cli parse -f input.html --minify
|
|
132
|
+
python -m pafer.cli sanitize -f input.html
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Exceptions
|
|
136
|
+
|
|
137
|
+
`HTMLParserError` (base), `SanitizationError`, `SelectorSyntaxError`, `ConfigLoadError`.
|
|
138
|
+
|
|
139
|
+
## LICENSE
|
|
140
|
+
|
|
141
|
+
MIT - Enjoy
|
|
142
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pafer/__init__.py,sha256=qfif1-tLgzb_L6E5dXvFrjr7KLHlUzlN-enrUQz-LSw,518
|
|
2
|
+
pafer/cache.py,sha256=_jLK3Cy9gZeKqdGqQGZ2lT3hGTyuMU9BB4Lb0MWWxrg,1442
|
|
3
|
+
pafer/cli.py,sha256=uUha1YFXnHtNYsFkWIyomjewOlvv9GAoXt1fDjIHREM,1459
|
|
4
|
+
pafer/config.py,sha256=RJ0lXrBrlbpW_SiUD1K5QGuyvm7wwdfH-3WAbJkcYG8,3669
|
|
5
|
+
pafer/exceptions.py,sha256=ThlTAu9APDe6eASRgJD9DA4L314GTLZd12w39mX1Sjk,444
|
|
6
|
+
pafer/exporters.py,sha256=fg_4ghpDFQQpcVp3y5dMIfszOqKJENhpKj2qEnSyfVk,537
|
|
7
|
+
pafer/feed.py,sha256=JiqfAMTCOWpVbqe--WXmwlSmcZK4gP36_HZIwtVAvtY,738
|
|
8
|
+
pafer/pafer.py,sha256=Lg6cHaiEakNvD8jzYFc_SrLwHXafLCTXX5bTaRLzBrA,4690
|
|
9
|
+
pafer/utils.py,sha256=ztX76pEHUFHQbFO5VXIUxfGrAVLU1QOAMOviA9AaC1U,1073
|
|
10
|
+
pafer-0.9.7.dist-info/licenses/LICENSE,sha256=v9KUTIwaghzDXke5a-bW9MVCQ27384A6Kse-TOKAIjc,1083
|
|
11
|
+
pafer-0.9.7.dist-info/METADATA,sha256=zLrmLrvkEF2SHiJMiytczEor-f5poTt2soQYL6qAuXA,4053
|
|
12
|
+
pafer-0.9.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
pafer-0.9.7.dist-info/top_level.txt,sha256=j9GNkUuPhxx2Smbk3kCwy7rbdq09_NjRkZ-suhl0glQ,6
|
|
14
|
+
pafer-0.9.7.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pafer
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pafer
|