email-reply-extractor 1.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.
- email_reply_extractor/__init__.py +49 -0
- email_reply_extractor/_fragments.py +130 -0
- email_reply_extractor/cleaning.py +601 -0
- email_reply_extractor/equivalence.py +43 -0
- email_reply_extractor/extraction.py +1007 -0
- email_reply_extractor/html_text.py +427 -0
- email_reply_extractor/py.typed +0 -0
- email_reply_extractor-1.1.0.dist-info/METADATA +243 -0
- email_reply_extractor-1.1.0.dist-info/RECORD +12 -0
- email_reply_extractor-1.1.0.dist-info/WHEEL +5 -0
- email_reply_extractor-1.1.0.dist-info/licenses/LICENSE +21 -0
- email_reply_extractor-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""email-reply-extractor: recover the author's newly written text from an email.
|
|
2
|
+
|
|
3
|
+
A two-stage, pure-stdlib pipeline for mailing-list mail:
|
|
4
|
+
|
|
5
|
+
- **Stage 1** (:func:`extract_new_text`) removes quoted text, attribution
|
|
6
|
+
lines, forwarded/quote-header blocks and other reproduced content, keeping
|
|
7
|
+
the author's full novel content — greetings, sign-offs and signatures
|
|
8
|
+
included.
|
|
9
|
+
- **Stage 2** (:func:`clean_for_scoring`) strips that formulaic furniture from
|
|
10
|
+
the stage-1 text, leaving only the author's substantive prose, and reports
|
|
11
|
+
which lines it removed.
|
|
12
|
+
|
|
13
|
+
:data:`EXTRACTION_VERSION` identifies the generation of the routine that
|
|
14
|
+
derives the text; it is independent of the package version and only ever
|
|
15
|
+
increases. See ``CONTRIBUTING.md`` for the contract.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .cleaning import CleanResult, clean_for_scoring
|
|
19
|
+
from .equivalence import texts_equivalent, tolerant_lines
|
|
20
|
+
from .extraction import (
|
|
21
|
+
EXTRACTION_VERSION,
|
|
22
|
+
STATUS_EMPTY,
|
|
23
|
+
STATUS_FAILED,
|
|
24
|
+
STATUS_OK,
|
|
25
|
+
ExtractionResult,
|
|
26
|
+
extract_new_text,
|
|
27
|
+
strip_parent_content,
|
|
28
|
+
)
|
|
29
|
+
from .html_text import HtmlParts, html_to_text, split_html_parts
|
|
30
|
+
|
|
31
|
+
__version__ = "1.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"EXTRACTION_VERSION",
|
|
35
|
+
"STATUS_EMPTY",
|
|
36
|
+
"STATUS_FAILED",
|
|
37
|
+
"STATUS_OK",
|
|
38
|
+
"CleanResult",
|
|
39
|
+
"ExtractionResult",
|
|
40
|
+
"HtmlParts",
|
|
41
|
+
"__version__",
|
|
42
|
+
"clean_for_scoring",
|
|
43
|
+
"extract_new_text",
|
|
44
|
+
"html_to_text",
|
|
45
|
+
"split_html_parts",
|
|
46
|
+
"strip_parent_content",
|
|
47
|
+
"texts_equivalent",
|
|
48
|
+
"tolerant_lines",
|
|
49
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Fragment scanner: segment an email body into quoted / unquoted fragments.
|
|
2
|
+
|
|
3
|
+
Vendored and trimmed from ``email_reply_parser`` 0.5.12 (Zapier's Python port
|
|
4
|
+
of GitHub's Email Reply Parser, <https://github.com/zapier/email-reply-parser>,
|
|
5
|
+
MIT License, Copyright (c) 2012 Zapier LLC) when this package absorbed its only
|
|
6
|
+
runtime dependency. Only the ``EmailReplyParser.read()`` path the extractor
|
|
7
|
+
consumes is kept: the bottom-up line scan that groups lines into fragments and
|
|
8
|
+
labels each fragment quoted or not. The upstream ``parse_reply`` entry point,
|
|
9
|
+
the ``reply`` property, the hidden/visible bookkeeping and the dead
|
|
10
|
+
``quote_header`` method are dropped — none of them influence fragment
|
|
11
|
+
boundaries, fragment content, or the ``quoted`` flag.
|
|
12
|
+
|
|
13
|
+
The scan works bottom-up: lines are visited last-to-first, and a fragment grows
|
|
14
|
+
while successive lines share its quoted/header classification (with the quirks
|
|
15
|
+
encoded in :func:`_scan_line` below). A blank line directly above a
|
|
16
|
+
signature-looking line closes the fragment, which is a boundary effect the
|
|
17
|
+
extractor's output depends on even though the signature label itself is not
|
|
18
|
+
reported.
|
|
19
|
+
|
|
20
|
+
Two upstream defects were preserved byte-for-byte through generation 4 and
|
|
21
|
+
fixed in generation 5 (see ``docs/generation-5.md``):
|
|
22
|
+
|
|
23
|
+
1. Upstream capped the Outlook-boundary newline fix at 8 replacements per
|
|
24
|
+
message by passing ``re.MULTILINE`` (value 8) as ``re.sub``'s positional
|
|
25
|
+
``count`` argument; the substitution now applies to every occurrence.
|
|
26
|
+
2. Upstream stripped each fragment's leading/trailing whitespace, so re-joining
|
|
27
|
+
fragments could glue unrelated lines together — a signature line directly
|
|
28
|
+
above a quote-header block's ``From:`` disguised the block from the
|
|
29
|
+
extractor's detector. Fragment content now keeps its edges, preserving the
|
|
30
|
+
blank lines that separate fragments.
|
|
31
|
+
|
|
32
|
+
Input is expected to be LF-normalized (:func:`.extraction.normalize_body` runs
|
|
33
|
+
first); the upstream CRLF replacement was dropped as unreachable.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
_SIG_RE = re.compile(r"(--|__|-\w)|(^Sent from my (\w+\s*){1,3})")
|
|
42
|
+
_QUOTE_HDR_RE = re.compile("On.*wrote:$")
|
|
43
|
+
_QUOTED_RE = re.compile(r"(>+)")
|
|
44
|
+
_HEADER_RE = re.compile(r"^\*?(From|Sent|To|Subject):\*? .+")
|
|
45
|
+
_MULTI_QUOTE_HDR = r"(?!On.*On\s.+?wrote:)(On\s(.+?)wrote:)"
|
|
46
|
+
#: Upstream's ``MULTI_QUOTE_HDR_REGEX`` (used for the substitution).
|
|
47
|
+
_MULTI_QUOTE_HDR_SUB_RE = re.compile(_MULTI_QUOTE_HDR, re.DOTALL | re.MULTILINE)
|
|
48
|
+
#: Upstream's ``MULTI_QUOTE_HDR_REGEX_MULTILINE`` (used for the search) — the
|
|
49
|
+
#: names are swapped relative to their flags upstream; both are kept verbatim.
|
|
50
|
+
_MULTI_QUOTE_HDR_SEARCH_RE = re.compile(_MULTI_QUOTE_HDR, re.DOTALL)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Fragment:
|
|
55
|
+
"""One scanned fragment: its content (edges kept) and whether it is quoted."""
|
|
56
|
+
|
|
57
|
+
content: str
|
|
58
|
+
quoted: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _OpenFragment:
|
|
62
|
+
"""A fragment still growing during the bottom-up scan."""
|
|
63
|
+
|
|
64
|
+
__slots__ = ("quoted", "headers", "lines")
|
|
65
|
+
|
|
66
|
+
def __init__(self, quoted: bool, first_line: str, headers: bool) -> None:
|
|
67
|
+
self.quoted = quoted
|
|
68
|
+
self.headers = headers
|
|
69
|
+
self.lines = [first_line]
|
|
70
|
+
|
|
71
|
+
def close(self) -> Fragment:
|
|
72
|
+
# Lines were appended in bottom-up scan order; restore document order.
|
|
73
|
+
# Edges are kept — see fix (2) in the module docstring.
|
|
74
|
+
return Fragment(content="\n".join(reversed(self.lines)), quoted=self.quoted)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_fragments(text: str) -> list[Fragment]:
|
|
78
|
+
"""Scan ``text`` (LF-normalized) into document-order fragments."""
|
|
79
|
+
match = _MULTI_QUOTE_HDR_SEARCH_RE.search(text)
|
|
80
|
+
if match:
|
|
81
|
+
# Collapse a wrapped multi-line "On … wrote:" header onto one line. The
|
|
82
|
+
# matched text is passed as a replacement *template*, exactly as
|
|
83
|
+
# upstream did — a body containing backslash escapes can make this
|
|
84
|
+
# raise, which the extractor's catch-all reports as a failed extraction.
|
|
85
|
+
text = _MULTI_QUOTE_HDR_SUB_RE.sub(match.groups()[0].replace("\n", ""), text)
|
|
86
|
+
|
|
87
|
+
# Break the reply off an Outlook-style boundary line it sits directly on
|
|
88
|
+
# top of — every occurrence; see fix (1) in the module docstring.
|
|
89
|
+
text = re.sub("([^\n])(?=\n ?[_-]{7,})", "\\1\n", text)
|
|
90
|
+
|
|
91
|
+
fragments: list[Fragment] = []
|
|
92
|
+
open_fragment: _OpenFragment | None = None
|
|
93
|
+
|
|
94
|
+
for line in reversed(text.split("\n")):
|
|
95
|
+
open_fragment = _scan_line(fragments, open_fragment, line)
|
|
96
|
+
|
|
97
|
+
if open_fragment is not None:
|
|
98
|
+
fragments.append(open_fragment.close())
|
|
99
|
+
|
|
100
|
+
fragments.reverse()
|
|
101
|
+
return fragments
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _scan_line(
|
|
105
|
+
fragments: list[Fragment], open_fragment: _OpenFragment | None, line: str
|
|
106
|
+
) -> _OpenFragment | None:
|
|
107
|
+
"""Fold one line (visited bottom-up) into the open fragment, or start a new one."""
|
|
108
|
+
is_quote_header = _QUOTE_HDR_RE.match(line) is not None
|
|
109
|
+
is_quoted = _QUOTED_RE.match(line) is not None
|
|
110
|
+
is_header = is_quote_header or _HEADER_RE.match(line) is not None
|
|
111
|
+
is_blank = len(line.strip()) == 0
|
|
112
|
+
|
|
113
|
+
# A blank line directly above a signature-looking line closes the fragment
|
|
114
|
+
# (the signature label itself was only consumed by the dropped hidden logic;
|
|
115
|
+
# the boundary is what matters here).
|
|
116
|
+
if open_fragment is not None and is_blank:
|
|
117
|
+
if _SIG_RE.match(open_fragment.lines[-1].strip()):
|
|
118
|
+
fragments.append(open_fragment.close())
|
|
119
|
+
open_fragment = None
|
|
120
|
+
|
|
121
|
+
if open_fragment is not None and (
|
|
122
|
+
(open_fragment.headers == is_header and open_fragment.quoted == is_quoted)
|
|
123
|
+
or (open_fragment.quoted and (is_quote_header or is_blank))
|
|
124
|
+
):
|
|
125
|
+
open_fragment.lines.append(line)
|
|
126
|
+
return open_fragment
|
|
127
|
+
|
|
128
|
+
if open_fragment is not None:
|
|
129
|
+
fragments.append(open_fragment.close())
|
|
130
|
+
return _OpenFragment(quoted=is_quoted, first_line=line, headers=is_header)
|