webpage-parser 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- webpage_parser-0.2.0/PKG-INFO +98 -0
- webpage_parser-0.2.0/README_PYPI.md +88 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/__init__.py +2 -2
- webpage_parser-0.2.0/extract.py +42 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/htmlprep.py +8 -1
- webpage_parser-0.2.0/pipeline.py +325 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/pubtime.py +96 -24
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/pyproject.toml +1 -1
- webpage_parser-0.2.0/util.py +250 -0
- webpage_parser-0.2.0/webpage_parser.egg-info/PKG-INFO +98 -0
- webpage_parser-0.1.0/PKG-INFO +0 -93
- webpage_parser-0.1.0/README_PYPI.md +0 -83
- webpage_parser-0.1.0/extract.py +0 -63
- webpage_parser-0.1.0/pipeline.py +0 -196
- webpage_parser-0.1.0/util.py +0 -95
- webpage_parser-0.1.0/webpage_parser.egg-info/PKG-INFO +0 -93
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/MANIFEST.in +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/author.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/content.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/pagegate.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/requirements.txt +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/setup.cfg +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/siteconfig.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/sitespecific.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/timetext.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/title.py +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/SOURCES.txt +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/dependency_links.txt +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/entry_points.txt +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/requires.txt +0 -0
- {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webpage-parser
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Extract structured fields (title, body, publication time, authors, language and more) from raw HTML pages
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: lxml==6.1.2
|
|
8
|
+
Requires-Dist: trafilatura==2.2.0
|
|
9
|
+
Requires-Dist: dateparser==1.4.2
|
|
10
|
+
|
|
11
|
+
# webpage-parser
|
|
12
|
+
|
|
13
|
+
Extract structured fields from a raw HTML page. Given one crawled web record,
|
|
14
|
+
`process_row` returns a fixed set of **35 fields** covering document identity,
|
|
15
|
+
URLs, the main article (title, body, headings, description, authors),
|
|
16
|
+
timestamps, language and content type.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install webpage-parser
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Python 3.10+ is required (`trafilatura` and `dateparser` need it).
|
|
25
|
+
|
|
26
|
+
## Python API
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from webpage_parser import process_row
|
|
30
|
+
|
|
31
|
+
row = {
|
|
32
|
+
"html": "<html>…</html>", # the main input
|
|
33
|
+
"url": "https://example.com/a", # everything except html is optional
|
|
34
|
+
}
|
|
35
|
+
result = process_row(row)
|
|
36
|
+
print(result["title"], result["published_at"], result["language"])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`process_row(row)` reads the following keys from the input dict. Only `html`
|
|
40
|
+
really matters; without usable HTML most output fields stay empty.
|
|
41
|
+
|
|
42
|
+
| key | meaning |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `html` | raw HTML, the main input |
|
|
45
|
+
| `url`, `durl`, `redirect_url` | candidate page URLs; first non-empty of `redirect_url` > `durl` > `url` wins |
|
|
46
|
+
| `purl` | passed through unchanged |
|
|
47
|
+
| `domain` | registrable-domain hint |
|
|
48
|
+
| `host` | full host-name hint |
|
|
49
|
+
| `headers` | HTTP response headers, a dict or a JSON string |
|
|
50
|
+
| `crawled_time`, `first_found_time`, `last_found_time` | epoch seconds (or milliseconds) |
|
|
51
|
+
| `anchor` | inbound anchor text |
|
|
52
|
+
| `in_domain_links`, `out_domain_links` | link counts; `(0, 0)` is treated as unknown |
|
|
53
|
+
| `raw_id`, `raw_id_version` | opaque document id / version |
|
|
54
|
+
| `extra` | optional dict; recognised sub-keys `time_zone` (offset in hours), `anchor`, `parser_name` |
|
|
55
|
+
| `img_ref` | optional dict of image references |
|
|
56
|
+
|
|
57
|
+
## Output
|
|
58
|
+
|
|
59
|
+
`process_row` returns a dict with exactly these 35 keys:
|
|
60
|
+
|
|
61
|
+
`document_id`, `document_version`, `url`, `url_hash`, `canonical_url`,
|
|
62
|
+
`canonical_url_hash`, `domain`, `registrable_domain`, `purl`, `site_name`,
|
|
63
|
+
`favicon_url`, `host_logo_url`, `image_url`, `title`, `headings`, `body`,
|
|
64
|
+
`description`, `authors`, `published_at`, `published_at_confidence`,
|
|
65
|
+
`published_at_source`, `updated_at`, `language`, `language_confidence`,
|
|
66
|
+
`country_codes`, `content_type`, `mime_type`, `content_return_policy`,
|
|
67
|
+
`parser_version`, `crawled_at`, `first_seen_at`, `last_seen_at`,
|
|
68
|
+
`anchor_text`, `in_domain_links`, `out_domain_links`.
|
|
69
|
+
|
|
70
|
+
Notes on selected fields:
|
|
71
|
+
|
|
72
|
+
- `title`, `body`, `description` are strings; `headings` and `authors` are lists.
|
|
73
|
+
- `domain` is the full host name; `registrable_domain` is the eTLD+1.
|
|
74
|
+
- `favicon_url`, `host_logo_url`, `image_url`, `country_codes` and
|
|
75
|
+
`content_return_policy` are placeholders that are currently always empty.
|
|
76
|
+
- `parser_version` is a constant string.
|
|
77
|
+
|
|
78
|
+
`empty_result(row)` is also exported and returns the same 35-key dict with the
|
|
79
|
+
HTML-derived fields left empty. `process_row` degrades to it whenever a page
|
|
80
|
+
cannot be parsed, so callers always receive the complete key set.
|
|
81
|
+
|
|
82
|
+
## Time zone
|
|
83
|
+
|
|
84
|
+
- `published_at` and `updated_at` are wall-clock strings `YYYY-MM-DD HH:MM:SS`
|
|
85
|
+
normalised to **UTC+8** (the default assumed site time zone). A value without
|
|
86
|
+
an explicit offset is interpreted using `extra["time_zone"]`, an offset in
|
|
87
|
+
hours, falling back to UTC+8 when absent.
|
|
88
|
+
- `crawled_at`, `first_seen_at` and `last_seen_at` are RFC 3339 timestamps in
|
|
89
|
+
**UTC** with a trailing `Z`, derived from the epoch inputs.
|
|
90
|
+
|
|
91
|
+
## Command line
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
webpage-parser --input rows.jsonl --output parsed.jsonl
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each input line is a JSON object shaped like the `process_row` argument; each
|
|
98
|
+
output line is the 35-field JSON result.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# webpage-parser
|
|
2
|
+
|
|
3
|
+
Extract structured fields from a raw HTML page. Given one crawled web record,
|
|
4
|
+
`process_row` returns a fixed set of **35 fields** covering document identity,
|
|
5
|
+
URLs, the main article (title, body, headings, description, authors),
|
|
6
|
+
timestamps, language and content type.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install webpage-parser
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Python 3.10+ is required (`trafilatura` and `dateparser` need it).
|
|
15
|
+
|
|
16
|
+
## Python API
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from webpage_parser import process_row
|
|
20
|
+
|
|
21
|
+
row = {
|
|
22
|
+
"html": "<html>…</html>", # the main input
|
|
23
|
+
"url": "https://example.com/a", # everything except html is optional
|
|
24
|
+
}
|
|
25
|
+
result = process_row(row)
|
|
26
|
+
print(result["title"], result["published_at"], result["language"])
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`process_row(row)` reads the following keys from the input dict. Only `html`
|
|
30
|
+
really matters; without usable HTML most output fields stay empty.
|
|
31
|
+
|
|
32
|
+
| key | meaning |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `html` | raw HTML, the main input |
|
|
35
|
+
| `url`, `durl`, `redirect_url` | candidate page URLs; first non-empty of `redirect_url` > `durl` > `url` wins |
|
|
36
|
+
| `purl` | passed through unchanged |
|
|
37
|
+
| `domain` | registrable-domain hint |
|
|
38
|
+
| `host` | full host-name hint |
|
|
39
|
+
| `headers` | HTTP response headers, a dict or a JSON string |
|
|
40
|
+
| `crawled_time`, `first_found_time`, `last_found_time` | epoch seconds (or milliseconds) |
|
|
41
|
+
| `anchor` | inbound anchor text |
|
|
42
|
+
| `in_domain_links`, `out_domain_links` | link counts; `(0, 0)` is treated as unknown |
|
|
43
|
+
| `raw_id`, `raw_id_version` | opaque document id / version |
|
|
44
|
+
| `extra` | optional dict; recognised sub-keys `time_zone` (offset in hours), `anchor`, `parser_name` |
|
|
45
|
+
| `img_ref` | optional dict of image references |
|
|
46
|
+
|
|
47
|
+
## Output
|
|
48
|
+
|
|
49
|
+
`process_row` returns a dict with exactly these 35 keys:
|
|
50
|
+
|
|
51
|
+
`document_id`, `document_version`, `url`, `url_hash`, `canonical_url`,
|
|
52
|
+
`canonical_url_hash`, `domain`, `registrable_domain`, `purl`, `site_name`,
|
|
53
|
+
`favicon_url`, `host_logo_url`, `image_url`, `title`, `headings`, `body`,
|
|
54
|
+
`description`, `authors`, `published_at`, `published_at_confidence`,
|
|
55
|
+
`published_at_source`, `updated_at`, `language`, `language_confidence`,
|
|
56
|
+
`country_codes`, `content_type`, `mime_type`, `content_return_policy`,
|
|
57
|
+
`parser_version`, `crawled_at`, `first_seen_at`, `last_seen_at`,
|
|
58
|
+
`anchor_text`, `in_domain_links`, `out_domain_links`.
|
|
59
|
+
|
|
60
|
+
Notes on selected fields:
|
|
61
|
+
|
|
62
|
+
- `title`, `body`, `description` are strings; `headings` and `authors` are lists.
|
|
63
|
+
- `domain` is the full host name; `registrable_domain` is the eTLD+1.
|
|
64
|
+
- `favicon_url`, `host_logo_url`, `image_url`, `country_codes` and
|
|
65
|
+
`content_return_policy` are placeholders that are currently always empty.
|
|
66
|
+
- `parser_version` is a constant string.
|
|
67
|
+
|
|
68
|
+
`empty_result(row)` is also exported and returns the same 35-key dict with the
|
|
69
|
+
HTML-derived fields left empty. `process_row` degrades to it whenever a page
|
|
70
|
+
cannot be parsed, so callers always receive the complete key set.
|
|
71
|
+
|
|
72
|
+
## Time zone
|
|
73
|
+
|
|
74
|
+
- `published_at` and `updated_at` are wall-clock strings `YYYY-MM-DD HH:MM:SS`
|
|
75
|
+
normalised to **UTC+8** (the default assumed site time zone). A value without
|
|
76
|
+
an explicit offset is interpreted using `extra["time_zone"]`, an offset in
|
|
77
|
+
hours, falling back to UTC+8 when absent.
|
|
78
|
+
- `crawled_at`, `first_seen_at` and `last_seen_at` are RFC 3339 timestamps in
|
|
79
|
+
**UTC** with a trailing `Z`, derived from the epoch inputs.
|
|
80
|
+
|
|
81
|
+
## Command line
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
webpage-parser --input rows.jsonl --output parsed.jsonl
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each input line is a JSON object shaped like the `process_row` argument; each
|
|
88
|
+
output line is the 35-field JSON result.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""网页解析算子:从爬取到的 HTML
|
|
1
|
+
"""网页解析算子:从爬取到的 HTML 原始行抽取结构化字段。
|
|
2
2
|
|
|
3
3
|
包内模块沿用「相对导入优先、扁平导入兜底」的双模式写法,因此既可以按包导入
|
|
4
4
|
(`from webpage_parser import process_row`),也可以把目录直接摊平当脚本跑
|
|
5
5
|
(`python3 extract.py`)。推荐按包导入。
|
|
6
6
|
"""
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
8
|
|
|
9
9
|
from .pipeline import empty_result, process_row
|
|
10
10
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CLI:逐行将上游网页 JSONL 转换为固定 35 字段结果。"""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from .pipeline import process_row, empty_result
|
|
8
|
+
except ImportError:
|
|
9
|
+
sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
|
10
|
+
from pipeline import process_row, empty_result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe_row(line):
|
|
14
|
+
try:
|
|
15
|
+
obj = json.loads(line)
|
|
16
|
+
return obj if isinstance(obj, dict) else None
|
|
17
|
+
except Exception:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
parser = argparse.ArgumentParser()
|
|
23
|
+
parser.add_argument("--input", required=True)
|
|
24
|
+
parser.add_argument("--output", required=True)
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
with open(args.input, "r", encoding="utf-8", errors="replace") as fin, \
|
|
28
|
+
open(args.output, "w", encoding="utf-8") as fout:
|
|
29
|
+
for line in fin:
|
|
30
|
+
if not line.strip():
|
|
31
|
+
continue
|
|
32
|
+
row = _safe_row(line)
|
|
33
|
+
try:
|
|
34
|
+
result = process_row(row) if row is not None else empty_result()
|
|
35
|
+
except Exception:
|
|
36
|
+
result = empty_result(row)
|
|
37
|
+
fout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
sys.exit(main())
|
|
@@ -172,6 +172,7 @@ class PageSignals:
|
|
|
172
172
|
self.visible_text_len = 0
|
|
173
173
|
self.title_tag = ""
|
|
174
174
|
self.h1_texts = []
|
|
175
|
+
self.canonical_urls = []
|
|
175
176
|
self.forum = False
|
|
176
177
|
self.ok = False
|
|
177
178
|
|
|
@@ -672,7 +673,13 @@ def _collect_avatar_byline(tree, signals):
|
|
|
672
673
|
|
|
673
674
|
|
|
674
675
|
def _collect_meta(tree, signals):
|
|
675
|
-
for el in tree.iter("meta"):
|
|
676
|
+
for el in tree.iter("meta", "link"):
|
|
677
|
+
if el.tag == "link":
|
|
678
|
+
rel = (el.get("rel") or "").lower().split()
|
|
679
|
+
href = clean_text(el.get("href") or "")
|
|
680
|
+
if "canonical" in rel and href:
|
|
681
|
+
signals.canonical_urls.append(href)
|
|
682
|
+
continue
|
|
676
683
|
key = el.get("name") or el.get("property") or el.get("itemprop") or ""
|
|
677
684
|
key = key.strip().lower() # 保留内部空格:weibo: article:create_at(调研05 坑)
|
|
678
685
|
if not key:
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""行级编排:一条上游网页记录转换为固定 35 字段输出。"""
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from urllib.parse import urljoin, urlparse
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from .util import (clean_text, norm_key, get_host, reg_domain, loop_unescape,
|
|
8
|
+
parse_headers, select_url, hash_url, epoch_to_rfc3339,
|
|
9
|
+
detect_content_type, detect_language)
|
|
10
|
+
from .htmlprep import parse_page
|
|
11
|
+
from .siteconfig import site_config
|
|
12
|
+
from .pagegate import gate
|
|
13
|
+
from .title import extract_title
|
|
14
|
+
from .author import extract_author
|
|
15
|
+
from .pubtime import extract_pubtime, pubtime_confidence
|
|
16
|
+
from .content import extract_content
|
|
17
|
+
from .timetext import parse_absolute
|
|
18
|
+
from .sitespecific import site_override
|
|
19
|
+
except ImportError:
|
|
20
|
+
from util import (clean_text, norm_key, get_host, reg_domain, loop_unescape,
|
|
21
|
+
parse_headers, select_url, hash_url, epoch_to_rfc3339,
|
|
22
|
+
detect_content_type, detect_language)
|
|
23
|
+
from htmlprep import parse_page
|
|
24
|
+
from siteconfig import site_config
|
|
25
|
+
from pagegate import gate
|
|
26
|
+
from title import extract_title
|
|
27
|
+
from author import extract_author
|
|
28
|
+
from pubtime import extract_pubtime, pubtime_confidence
|
|
29
|
+
from content import extract_content
|
|
30
|
+
from timetext import parse_absolute
|
|
31
|
+
from sitespecific import site_override
|
|
32
|
+
|
|
33
|
+
PARSER_VERSION = "1.0.0"
|
|
34
|
+
OUTPUT_FIELDS = (
|
|
35
|
+
"document_id", "document_version", "url", "url_hash", "canonical_url",
|
|
36
|
+
"canonical_url_hash", "domain", "registrable_domain", "purl", "site_name",
|
|
37
|
+
"favicon_url", "host_logo_url", "image_url", "title", "headings", "body",
|
|
38
|
+
"description", "authors", "published_at", "published_at_confidence",
|
|
39
|
+
"published_at_source", "updated_at", "language", "language_confidence",
|
|
40
|
+
"country_codes", "content_type", "mime_type", "content_return_policy",
|
|
41
|
+
"parser_version", "crawled_at", "first_seen_at", "last_seen_at",
|
|
42
|
+
"anchor_text", "in_domain_links", "out_domain_links",
|
|
43
|
+
)
|
|
44
|
+
_SITE_TZ_SKEW = timedelta(minutes=10)
|
|
45
|
+
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", re.M)
|
|
46
|
+
_SITE_SUFFIX_RE = re.compile(r"^\s*[-–—_|·•::]+\s*(.+?)\s*$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _string_or_none(value):
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
text = str(value).strip()
|
|
53
|
+
return text or None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_zero(value):
|
|
57
|
+
try:
|
|
58
|
+
return float(value) == 0
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def empty_result(row=None):
|
|
64
|
+
row = row if isinstance(row, dict) else {}
|
|
65
|
+
url = select_url(row)
|
|
66
|
+
domain = _string_or_none(row.get("host")) or _string_or_none(get_host(url or ""))
|
|
67
|
+
registrable = _string_or_none(row.get("domain")) or \
|
|
68
|
+
_string_or_none(reg_domain(domain or ""))
|
|
69
|
+
in_links = row.get("in_domain_links")
|
|
70
|
+
out_links = row.get("out_domain_links")
|
|
71
|
+
if _is_zero(in_links) and _is_zero(out_links):
|
|
72
|
+
in_links = None
|
|
73
|
+
out_links = None
|
|
74
|
+
return {
|
|
75
|
+
"document_id": _string_or_none(row.get("raw_id")),
|
|
76
|
+
"document_version": _string_or_none(row.get("raw_id_version")),
|
|
77
|
+
"url": url,
|
|
78
|
+
"url_hash": hash_url(url),
|
|
79
|
+
"canonical_url": None,
|
|
80
|
+
"canonical_url_hash": None,
|
|
81
|
+
"domain": domain,
|
|
82
|
+
"registrable_domain": registrable,
|
|
83
|
+
"purl": row.get("purl"),
|
|
84
|
+
"site_name": None,
|
|
85
|
+
"favicon_url": None,
|
|
86
|
+
"host_logo_url": None,
|
|
87
|
+
"image_url": None,
|
|
88
|
+
"title": None,
|
|
89
|
+
"headings": [],
|
|
90
|
+
"body": None,
|
|
91
|
+
"description": None,
|
|
92
|
+
"authors": [],
|
|
93
|
+
"published_at": None,
|
|
94
|
+
"published_at_confidence": None,
|
|
95
|
+
"published_at_source": None,
|
|
96
|
+
"updated_at": None,
|
|
97
|
+
"language": "und",
|
|
98
|
+
"language_confidence": 0.0,
|
|
99
|
+
"country_codes": None,
|
|
100
|
+
"content_type": None,
|
|
101
|
+
"mime_type": None,
|
|
102
|
+
"content_return_policy": None,
|
|
103
|
+
"parser_version": PARSER_VERSION,
|
|
104
|
+
"crawled_at": epoch_to_rfc3339(row.get("crawled_time")),
|
|
105
|
+
"first_seen_at": epoch_to_rfc3339(row.get("first_found_time"), reject_zero=True),
|
|
106
|
+
"last_seen_at": epoch_to_rfc3339(row.get("last_found_time")),
|
|
107
|
+
"anchor_text": _string_or_none(row.get("anchor")),
|
|
108
|
+
"in_domain_links": in_links,
|
|
109
|
+
"out_domain_links": out_links,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _visible_text(tree):
|
|
114
|
+
try:
|
|
115
|
+
body = tree.find("body")
|
|
116
|
+
root = body if body is not None else tree
|
|
117
|
+
for bad in root.iter("script", "style", "noscript", "template"):
|
|
118
|
+
parent = bad.getparent()
|
|
119
|
+
if parent is not None:
|
|
120
|
+
parent.remove(bad)
|
|
121
|
+
return root.text_content() or ""
|
|
122
|
+
except Exception:
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_crawl_time(row):
|
|
127
|
+
value = row.get("crawled_time")
|
|
128
|
+
if value is None:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
131
|
+
try:
|
|
132
|
+
seconds = float(value)
|
|
133
|
+
if abs(seconds) >= 100_000_000_000:
|
|
134
|
+
seconds /= 1000.0
|
|
135
|
+
return datetime.utcfromtimestamp(seconds) + timedelta(hours=8)
|
|
136
|
+
except (TypeError, ValueError, OverflowError, OSError):
|
|
137
|
+
return None
|
|
138
|
+
text = str(value).strip()
|
|
139
|
+
parsed = parse_absolute(text)
|
|
140
|
+
return parsed.dt if parsed else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _first_meta(signals, *keys):
|
|
144
|
+
for key in keys:
|
|
145
|
+
for value in signals.meta.get(key, []):
|
|
146
|
+
text = clean_text(value)
|
|
147
|
+
if text:
|
|
148
|
+
return text
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _canonical_url(signals, page_url):
|
|
153
|
+
base_url = page_url or ""
|
|
154
|
+
try:
|
|
155
|
+
bases = signals.tree.xpath("//base[@href][1]")
|
|
156
|
+
if bases:
|
|
157
|
+
candidate_base = urljoin(base_url, clean_text(bases[0].get("href") or ""))
|
|
158
|
+
parsed_base = urlparse(candidate_base)
|
|
159
|
+
if parsed_base.scheme.lower() in ("http", "https") and parsed_base.hostname:
|
|
160
|
+
base_url = candidate_base
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
candidates = list(signals.canonical_urls)
|
|
164
|
+
candidates.extend(signals.meta.get("og:url", []))
|
|
165
|
+
for candidate in candidates:
|
|
166
|
+
try:
|
|
167
|
+
resolved = urljoin(base_url, clean_text(candidate))
|
|
168
|
+
parsed = urlparse(resolved)
|
|
169
|
+
if parsed.scheme.lower() in ("http", "https") and parsed.hostname:
|
|
170
|
+
return resolved
|
|
171
|
+
except Exception:
|
|
172
|
+
continue
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _site_name(signals, title, suffix_stripped):
|
|
177
|
+
explicit = _first_meta(signals, "og:site_name")
|
|
178
|
+
if explicit:
|
|
179
|
+
return explicit
|
|
180
|
+
if not title or not suffix_stripped:
|
|
181
|
+
return None
|
|
182
|
+
raw_titles = list(signals.meta.get("og:title", []))
|
|
183
|
+
if signals.title_tag:
|
|
184
|
+
raw_titles.append(signals.title_tag)
|
|
185
|
+
for raw in raw_titles:
|
|
186
|
+
raw = clean_text(raw)
|
|
187
|
+
if not raw.startswith(title):
|
|
188
|
+
continue
|
|
189
|
+
match = _SITE_SUFFIX_RE.match(raw[len(title):])
|
|
190
|
+
if match:
|
|
191
|
+
value = clean_text(match.group(1))
|
|
192
|
+
if 1 < len(value) <= 80:
|
|
193
|
+
return value
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _authors(value):
|
|
198
|
+
if not value:
|
|
199
|
+
return []
|
|
200
|
+
result = []
|
|
201
|
+
for item in str(value).split("、"):
|
|
202
|
+
item = clean_text(item)
|
|
203
|
+
if item and item not in result:
|
|
204
|
+
result.append(item)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _headings(markdown):
|
|
209
|
+
result = []
|
|
210
|
+
seen = set()
|
|
211
|
+
for match in _HEADING_RE.finditer(markdown or ""):
|
|
212
|
+
value = clean_text(match.group(1))
|
|
213
|
+
if value and value not in seen:
|
|
214
|
+
seen.add(value)
|
|
215
|
+
result.append(value)
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _declared_language(signals):
|
|
220
|
+
try:
|
|
221
|
+
roots = signals.tree.xpath("//html[@lang][1]")
|
|
222
|
+
if roots:
|
|
223
|
+
return roots[0].get("lang") or ""
|
|
224
|
+
return signals.tree.get("lang") or ""
|
|
225
|
+
except Exception:
|
|
226
|
+
return ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def process_row(row):
|
|
230
|
+
out = empty_result(row)
|
|
231
|
+
raw_html = row.get("html")
|
|
232
|
+
if not isinstance(raw_html, str):
|
|
233
|
+
raw_html = ""
|
|
234
|
+
headers = parse_headers(row.get("headers"))
|
|
235
|
+
content_type, mime_type = detect_content_type(headers, raw_html)
|
|
236
|
+
out["content_type"] = content_type
|
|
237
|
+
out["mime_type"] = mime_type
|
|
238
|
+
if not raw_html.strip():
|
|
239
|
+
return out
|
|
240
|
+
if len(raw_html) > 8_000_000:
|
|
241
|
+
raw_html = raw_html[:8_000_000]
|
|
242
|
+
|
|
243
|
+
url = out["url"] or ""
|
|
244
|
+
host = get_host(url) or out["domain"] or ""
|
|
245
|
+
extra = row.get("extra") if isinstance(row.get("extra"), dict) else {}
|
|
246
|
+
if out["anchor_text"] and not extra.get("anchor"):
|
|
247
|
+
extra = dict(extra)
|
|
248
|
+
extra["anchor"] = out["anchor_text"]
|
|
249
|
+
parser_name = extra.get("parser_name")
|
|
250
|
+
images = row.get("_IMAGES") if isinstance(row.get("_IMAGES"), list) else []
|
|
251
|
+
img_ref = row.get("img_ref") if isinstance(row.get("img_ref"), dict) else {}
|
|
252
|
+
crawl_dt = _parse_crawl_time(row)
|
|
253
|
+
cfg = site_config(host, parser_name)
|
|
254
|
+
signals = parse_page(raw_html, url=url, host=host,
|
|
255
|
+
collect_state=cfg.allow_l1b,
|
|
256
|
+
forum=cfg.page_type in ("forum_page", "comment_page"))
|
|
257
|
+
if not signals.ok:
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
canonical = _canonical_url(signals, url)
|
|
261
|
+
out["canonical_url"] = canonical
|
|
262
|
+
out["canonical_url_hash"] = hash_url(canonical)
|
|
263
|
+
out["description"] = _first_meta(signals, "description", "og:description")
|
|
264
|
+
out["site_name"] = _first_meta(signals, "og:site_name")
|
|
265
|
+
|
|
266
|
+
page_status, _ = gate(signals, cfg, headers)
|
|
267
|
+
visible_text = _visible_text(signals.tree)
|
|
268
|
+
if page_status == "list_page":
|
|
269
|
+
language, confidence = detect_language(visible_text, _declared_language(signals))
|
|
270
|
+
out["language"] = language
|
|
271
|
+
out["language_confidence"] = confidence
|
|
272
|
+
return out
|
|
273
|
+
visible_norm = norm_key(visible_text)
|
|
274
|
+
html_norm = norm_key(loop_unescape(raw_html[:1_500_000]))
|
|
275
|
+
|
|
276
|
+
title, _, suffix_stripped = extract_title(signals, cfg, html_norm)
|
|
277
|
+
author, _, _ = extract_author(
|
|
278
|
+
signals, cfg, extra, visible_norm, html_norm, title=title)
|
|
279
|
+
|
|
280
|
+
anchor = ""
|
|
281
|
+
if cfg.allow_anchor_author:
|
|
282
|
+
anchor = clean_text(str(extra.get("anchor") or ""))
|
|
283
|
+
pubtime = extract_pubtime(signals, cfg, crawl_dt, extra, headers, url=url,
|
|
284
|
+
anchor=anchor)
|
|
285
|
+
|
|
286
|
+
if page_status == "spa_empty":
|
|
287
|
+
body = ""
|
|
288
|
+
else:
|
|
289
|
+
body, _, _, _ = extract_content(
|
|
290
|
+
signals, cfg, page_status, images, img_ref, visible_norm,
|
|
291
|
+
title=title, url=url, pub_time=pubtime["pub_time"])
|
|
292
|
+
|
|
293
|
+
override = site_override(host, signals, extra)
|
|
294
|
+
if override:
|
|
295
|
+
if override.get("content"):
|
|
296
|
+
body = override["content"]
|
|
297
|
+
if override.get("title"):
|
|
298
|
+
title = override["title"]
|
|
299
|
+
if override.get("author") and not author:
|
|
300
|
+
author = override["author"]
|
|
301
|
+
if override.get("pub_time_raw") and not pubtime["pub_time"]:
|
|
302
|
+
parsed = parse_absolute(override["pub_time_raw"])
|
|
303
|
+
if parsed and (not crawl_dt or parsed.dt <= crawl_dt + _SITE_TZ_SKEW):
|
|
304
|
+
pubtime["pub_time"] = parsed.fmt()
|
|
305
|
+
pubtime["pub_time_source"] = "site_rule"
|
|
306
|
+
pubtime["pub_time_precision"] = parsed.precision
|
|
307
|
+
pubtime["pub_time_tz_offset"] = parsed.tz_offset
|
|
308
|
+
pubtime["pub_time_raw"] = parsed.raw[:120]
|
|
309
|
+
|
|
310
|
+
out["title"] = title or None
|
|
311
|
+
out["body"] = body or None
|
|
312
|
+
out["authors"] = _authors(author)
|
|
313
|
+
out["headings"] = _headings(body)
|
|
314
|
+
if not out["site_name"]:
|
|
315
|
+
out["site_name"] = _site_name(signals, title, suffix_stripped)
|
|
316
|
+
out["published_at"] = pubtime["pub_time"] or None
|
|
317
|
+
out["published_at_source"] = pubtime["pub_time_source"] \
|
|
318
|
+
if pubtime["pub_time_source"] != "none" else None
|
|
319
|
+
out["published_at_confidence"] = pubtime_confidence(pubtime)
|
|
320
|
+
out["updated_at"] = pubtime["updated_at"]
|
|
321
|
+
language, confidence = detect_language(body or visible_text or title,
|
|
322
|
+
_declared_language(signals))
|
|
323
|
+
out["language"] = language
|
|
324
|
+
out["language_confidence"] = confidence
|
|
325
|
+
return out
|