scrollbreaker 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.
- scrollbreaker/__init__.py +32 -0
- scrollbreaker/accumulate.py +204 -0
- scrollbreaker/cli.py +180 -0
- scrollbreaker/config.py +88 -0
- scrollbreaker/dedup.py +110 -0
- scrollbreaker/ocr.py +91 -0
- scrollbreaker/pagetype.py +165 -0
- scrollbreaker-0.1.0.dist-info/METADATA +231 -0
- scrollbreaker-0.1.0.dist-info/RECORD +13 -0
- scrollbreaker-0.1.0.dist-info/WHEEL +5 -0
- scrollbreaker-0.1.0.dist-info/entry_points.txt +2 -0
- scrollbreaker-0.1.0.dist-info/licenses/LICENSE +21 -0
- scrollbreaker-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""scrollbreaker —— 把滚动的屏幕,切成能读的页。
|
|
2
|
+
|
|
3
|
+
核心问题:手机/电脑上读长内容时,一屏只能看到一小段。连续截屏会有三种噪声:
|
|
4
|
+
1. 屏幕没动,重复截到同一屏(浪费 token)
|
|
5
|
+
2. 内容滚动了,前后两屏有一段重叠(直接拼会重复)
|
|
6
|
+
3. **换页面了**,但上一屏和这一屏恰好有相同的一行(直接拼会把两件事糊成一件)
|
|
7
|
+
|
|
8
|
+
前两个好解决,第三个才是真正会毁掉数据的问题。scrollbreaker 用四道闸门判定
|
|
9
|
+
「这两屏到底是不是同一页」,只有全过才拼,否则另起一条。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .config import Config, DEFAULT_CONFIG
|
|
13
|
+
from .pagetype import page_type, head_anchor, body_lines
|
|
14
|
+
from .accumulate import Accumulator, Frame, Merge, Mode
|
|
15
|
+
from .dedup import dhash, is_duplicate, is_blank
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Config",
|
|
19
|
+
"DEFAULT_CONFIG",
|
|
20
|
+
"page_type",
|
|
21
|
+
"head_anchor",
|
|
22
|
+
"body_lines",
|
|
23
|
+
"Accumulator",
|
|
24
|
+
"Frame",
|
|
25
|
+
"Merge",
|
|
26
|
+
"Mode",
|
|
27
|
+
"dhash",
|
|
28
|
+
"is_duplicate",
|
|
29
|
+
"is_blank",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""累积器:判定「这一屏和上一屏,到底是不是同一页」。
|
|
2
|
+
|
|
3
|
+
这是整个项目最核心的一块,也是最容易做错的一块。
|
|
4
|
+
|
|
5
|
+
反面教材(早期实现):
|
|
6
|
+
只看「两屏有几行重合」就拼。实测中,公众号文章和聊天窗口之间恰好
|
|
7
|
+
重合了 6 行(都是「正常」「好的」这类短句),于是把一篇文章和一串
|
|
8
|
+
车间对话拼成了一条记录——**数据污染了,而且看不出来**。
|
|
9
|
+
|
|
10
|
+
正确做法:四道闸门全部通过,才允许合并。
|
|
11
|
+
|
|
12
|
+
① 时间窗 两屏间隔 ≤ window_s(默认 180s)。再久就是「过了一会儿又看」
|
|
13
|
+
② 类型一致 两屏 page_type 相同,且都不是 mixed
|
|
14
|
+
③ 页头一致 页头锚点相同(article 例外:滚动时首行会变,同类型即认同一篇)
|
|
15
|
+
④ 有实质重合 完全相同行的数量 ≥ min_overlap(默认 2)
|
|
16
|
+
|
|
17
|
+
任一条不过 = 换页了 = 另起一条,绝不拼。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import Sequence
|
|
25
|
+
|
|
26
|
+
from .config import CompiledPatterns, Config, DEFAULT_CONFIG
|
|
27
|
+
from .pagetype import (
|
|
28
|
+
ACCUMULABLE,
|
|
29
|
+
ARTICLE,
|
|
30
|
+
CHAT,
|
|
31
|
+
MIXED,
|
|
32
|
+
body_lines,
|
|
33
|
+
chat_anchor,
|
|
34
|
+
head_anchor,
|
|
35
|
+
normalize,
|
|
36
|
+
page_type,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Mode(str, Enum):
|
|
41
|
+
"""这一条记录是怎么来的。"""
|
|
42
|
+
|
|
43
|
+
ROLL = "roll" # 与上一屏同页,合并而成
|
|
44
|
+
NEW = "new" # 换页了,独立成条
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Frame:
|
|
49
|
+
"""一屏采样。"""
|
|
50
|
+
|
|
51
|
+
lines: Sequence[str]
|
|
52
|
+
ts: float # 采样时间(秒,epoch)
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
self.lines = list(self.lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Merge:
|
|
60
|
+
"""一次 push 的结果。"""
|
|
61
|
+
|
|
62
|
+
mode: Mode
|
|
63
|
+
lines: list[str]
|
|
64
|
+
page: str # 页面类型
|
|
65
|
+
anchor: str # 页头锚点
|
|
66
|
+
overlap: int # 与上一屏的重合行数
|
|
67
|
+
scene: str # "<page>|<anchor>",同一 scene 才是同一次阅读
|
|
68
|
+
text: str # 最终文本(已按 max_text_chars 截断)
|
|
69
|
+
gate_log: dict = field(default_factory=dict) # 每道闸门的判定结果,便于排查
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def line_count(self) -> int:
|
|
73
|
+
return len(self.lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _same_page(
|
|
77
|
+
prev: dict, ptype: str, phead: str, overlap: int, cur_ts: float, cfg: Config
|
|
78
|
+
) -> tuple[bool, dict]:
|
|
79
|
+
"""四道闸门。返回 (是否同页, 逐闸trace)。"""
|
|
80
|
+
prev_lines = prev.get("lines") or []
|
|
81
|
+
prev_type = prev.get("type") or ""
|
|
82
|
+
prev_head = prev.get("head") or ""
|
|
83
|
+
prev_ts = float(prev.get("ts") or 0.0)
|
|
84
|
+
|
|
85
|
+
g1_time = bool(prev_lines) and (cur_ts - prev_ts) <= cfg.window_s
|
|
86
|
+
g2_type = prev_type == ptype and prev_type != MIXED and ptype != MIXED
|
|
87
|
+
g3_head = prev_head == phead
|
|
88
|
+
g4_overlap = overlap >= cfg.min_overlap
|
|
89
|
+
|
|
90
|
+
# 文章页滚动时首行会变(标题滚出屏幕),靠「类型=article」认同一篇
|
|
91
|
+
g3_pass = True if ptype == ARTICLE else g3_head
|
|
92
|
+
|
|
93
|
+
ok = g1_time and g2_type and g3_pass and g4_overlap
|
|
94
|
+
trace = {
|
|
95
|
+
"time_window": g1_time,
|
|
96
|
+
"same_type": g2_type,
|
|
97
|
+
"same_anchor": g3_head,
|
|
98
|
+
"anchor_relaxed": ptype == ARTICLE,
|
|
99
|
+
"overlap_enough": g4_overlap,
|
|
100
|
+
}
|
|
101
|
+
return ok, trace
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Accumulator:
|
|
105
|
+
"""有状态的累积器:喂进一屏一屏的文本,吐出「该不该合并」。
|
|
106
|
+
|
|
107
|
+
用法:
|
|
108
|
+
acc = Accumulator()
|
|
109
|
+
for snap in snapshots: # 每 N 秒一屏
|
|
110
|
+
m = acc.push(Frame(lines=snap.lines, ts=snap.ts))
|
|
111
|
+
if m.mode is Mode.ROLL: # 同一页在滚动,用 m.text(完整累积)
|
|
112
|
+
...
|
|
113
|
+
else: # 换页了,m.text 是这一页的第一屏
|
|
114
|
+
...
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, cfg: Config = DEFAULT_CONFIG):
|
|
118
|
+
self.cfg = cfg
|
|
119
|
+
self.pats: CompiledPatterns = cfg.compiled()
|
|
120
|
+
self._prev: dict = {}
|
|
121
|
+
self._last_norm: str | None = None
|
|
122
|
+
|
|
123
|
+
# ── 状态 ────────────────────────────────────────────────
|
|
124
|
+
@property
|
|
125
|
+
def current_lines(self) -> list[str]:
|
|
126
|
+
return list(self._prev.get("lines") or [])
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def current_page(self) -> str:
|
|
130
|
+
return str(self._prev.get("type") or "")
|
|
131
|
+
|
|
132
|
+
def reset(self) -> None:
|
|
133
|
+
self._prev = {}
|
|
134
|
+
self._last_norm = None
|
|
135
|
+
|
|
136
|
+
# ── 主流程 ──────────────────────────────────────────────
|
|
137
|
+
def push(self, frame: Frame) -> Merge:
|
|
138
|
+
keep = [l for l in frame.lines if l.strip()]
|
|
139
|
+
ptype = page_type(keep, self.cfg, self.pats)
|
|
140
|
+
# 聊天页用发言人集合做锚点(页头可能已滚出屏幕),其余用首行
|
|
141
|
+
if ptype == CHAT:
|
|
142
|
+
phead = chat_anchor(keep, self.cfg, self.pats)
|
|
143
|
+
else:
|
|
144
|
+
phead = head_anchor(keep, self.cfg, self.pats)
|
|
145
|
+
|
|
146
|
+
prev = self._prev
|
|
147
|
+
# 重合判定必须基于「上一帧真实观测到的行」(raw),不能用已剥横幅的输出版本,
|
|
148
|
+
# 否则文章页剥掉顶部几行后,下一帧就再也算不出重合,会误判成换页。
|
|
149
|
+
prev_observed = [str(x) for x in (prev.get("raw") or prev.get("lines") or [])]
|
|
150
|
+
prev_lines = [str(x) for x in (prev.get("lines") or [])]
|
|
151
|
+
overlap = len(set(keep) & set(prev_observed))
|
|
152
|
+
|
|
153
|
+
same, trace = _same_page(prev, ptype, phead, overlap, frame.ts, self.cfg)
|
|
154
|
+
|
|
155
|
+
if same:
|
|
156
|
+
# 文章页要剥掉顶部的消息横幅,否则正文里夹一句聊天
|
|
157
|
+
incoming = body_lines(keep, self.cfg) if ptype == ARTICLE else keep
|
|
158
|
+
merged = list(prev_lines)
|
|
159
|
+
seen = set(merged)
|
|
160
|
+
for line in incoming:
|
|
161
|
+
if line not in seen: # 保序去重
|
|
162
|
+
merged.append(line)
|
|
163
|
+
seen.add(line)
|
|
164
|
+
if len(merged) > self.cfg.max_lines:
|
|
165
|
+
merged = merged[-self.cfg.max_lines:]
|
|
166
|
+
mode = Mode.ROLL
|
|
167
|
+
else:
|
|
168
|
+
# 新起一条时,文章页同样要剥掉顶部横幅——否则「在吗」就混进正文了
|
|
169
|
+
base = body_lines(keep, self.cfg) if ptype == ARTICLE else keep
|
|
170
|
+
merged = list(base)[-self.cfg.max_lines:]
|
|
171
|
+
mode = Mode.NEW
|
|
172
|
+
|
|
173
|
+
self._prev = {
|
|
174
|
+
"lines": merged,
|
|
175
|
+
"raw": list(keep), # 本帧原始观测行,用于下一帧的重合判定
|
|
176
|
+
"ts": frame.ts,
|
|
177
|
+
"type": ptype,
|
|
178
|
+
"head": phead,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
text = "\n".join(merged)[: self.cfg.max_text_chars]
|
|
182
|
+
return Merge(
|
|
183
|
+
mode=mode,
|
|
184
|
+
lines=merged,
|
|
185
|
+
page=ptype,
|
|
186
|
+
anchor=phead,
|
|
187
|
+
overlap=overlap,
|
|
188
|
+
scene=f"{ptype}|{phead}",
|
|
189
|
+
text=text,
|
|
190
|
+
gate_log=trace,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def push_text(self, raw_text: str, ts: float, splitter=None) -> Merge:
|
|
194
|
+
"""便捷入口:直接喂 OCR 后的整段文本。"""
|
|
195
|
+
lines = splitter(raw_text) if splitter else [l.strip() for l in raw_text.splitlines() if l.strip()]
|
|
196
|
+
return self.push(Frame(lines=lines, ts=ts))
|
|
197
|
+
|
|
198
|
+
# ── 文本层去重(配合 dedup.Deduper) ──────────────────
|
|
199
|
+
def is_text_unchanged(self, merge: Merge) -> bool:
|
|
200
|
+
"""整段归一化后与上次完全一致 → 内容没变,不必重复上报。"""
|
|
201
|
+
n = normalize(merge.text, self.cfg)
|
|
202
|
+
unchanged = (n == self._last_norm) or (n == "")
|
|
203
|
+
self._last_norm = n
|
|
204
|
+
return unchanged
|
scrollbreaker/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""命令行入口。
|
|
2
|
+
|
|
3
|
+
两个常用子命令:
|
|
4
|
+
|
|
5
|
+
replay 拿一段「多帧文本」的 JSONL 离线跑分页逻辑,验证你的参数
|
|
6
|
+
(不用真截屏,最适合调阈值 / 写回归)
|
|
7
|
+
|
|
8
|
+
watch 真的去截屏 + OCR + 累积,把结果追加到 jsonl
|
|
9
|
+
|
|
10
|
+
watch 在 Windows/macOS 上都能用(依赖 mss),但你要自己保证被采集的窗口
|
|
11
|
+
在前台 —— 本项目只负责「看图说话」,不负责把窗口调出来。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .accumulate import Accumulator, Frame, Mode
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .dedup import Deduper, is_blank
|
|
25
|
+
from .ocr import CmdOCR, NullOCR
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cfg_from_args(a) -> Config:
|
|
29
|
+
cfg = Config()
|
|
30
|
+
if a.window_s is not None:
|
|
31
|
+
cfg.window_s = a.window_s
|
|
32
|
+
if a.max_lines is not None:
|
|
33
|
+
cfg.max_lines = a.max_lines
|
|
34
|
+
if a.min_overlap is not None:
|
|
35
|
+
cfg.min_overlap = a.min_overlap
|
|
36
|
+
return cfg
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_replay(a) -> int:
|
|
40
|
+
"""离线回放:每行一个 JSON,形如 {"ts": 1000.0, "lines": ["...", "..."]}。"""
|
|
41
|
+
cfg = _cfg_from_args(a)
|
|
42
|
+
acc = Accumulator(cfg)
|
|
43
|
+
n_roll = n_new = 0
|
|
44
|
+
with open(a.input, encoding="utf-8") as f:
|
|
45
|
+
for lineno, line in enumerate(f, 1):
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if not line:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
rec = json.loads(line)
|
|
51
|
+
except json.JSONDecodeError as e:
|
|
52
|
+
print(f"[跳过] 第 {lineno} 行不是合法 JSON: {e}", file=sys.stderr)
|
|
53
|
+
continue
|
|
54
|
+
frame = Frame(lines=rec.get("lines") or [], ts=float(rec.get("ts") or lineno))
|
|
55
|
+
m = acc.push(frame)
|
|
56
|
+
n_roll += m.mode is Mode.ROLL
|
|
57
|
+
n_new += m.mode is Mode.NEW
|
|
58
|
+
print(
|
|
59
|
+
json.dumps(
|
|
60
|
+
{
|
|
61
|
+
"frame": lineno,
|
|
62
|
+
"mode": m.mode.value,
|
|
63
|
+
"page": m.page,
|
|
64
|
+
"anchor": m.anchor,
|
|
65
|
+
"overlap": m.overlap,
|
|
66
|
+
"lines": m.line_count,
|
|
67
|
+
"gate": m.gate_log,
|
|
68
|
+
},
|
|
69
|
+
ensure_ascii=False,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
print(f"\n合计 {n_new} 条新页 / {n_roll} 次合并", file=sys.stderr)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_watch(a) -> int:
|
|
77
|
+
"""真截屏循环。"""
|
|
78
|
+
try:
|
|
79
|
+
import mss # noqa: F401
|
|
80
|
+
from PIL import Image
|
|
81
|
+
except ImportError:
|
|
82
|
+
print("watch 需要 mss 和 Pillow:pip install mss pillow", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
import io
|
|
86
|
+
import mss
|
|
87
|
+
|
|
88
|
+
cfg = _cfg_from_args(a)
|
|
89
|
+
ocr = CmdOCR(a.ocr_cmd) if a.ocr_cmd else NullOCR()
|
|
90
|
+
acc = Accumulator(cfg)
|
|
91
|
+
ded = Deduper(cfg)
|
|
92
|
+
out = open(a.output, "a", encoding="utf-8")
|
|
93
|
+
sct = mss.mss()
|
|
94
|
+
mon = sct.monitors[a.monitor]
|
|
95
|
+
print(f"采集区域 {mon},间隔 {a.interval}s,输出 {a.output}", file=sys.stderr)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
while True:
|
|
99
|
+
shot = sct.grab(mon)
|
|
100
|
+
buf = io.BytesIO()
|
|
101
|
+
Image.frombytes("RGB", shot.size, shot.rgb).save(buf, format="PNG")
|
|
102
|
+
png = buf.getvalue()
|
|
103
|
+
|
|
104
|
+
blank, mean, std = is_blank(png, cfg)
|
|
105
|
+
if blank:
|
|
106
|
+
time.sleep(a.interval)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
dup, _h = ded.seen_pixels(png)
|
|
110
|
+
if dup:
|
|
111
|
+
time.sleep(a.interval)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
text = ocr.read(png)
|
|
115
|
+
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
116
|
+
if not lines:
|
|
117
|
+
time.sleep(a.interval)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
m = acc.push(Frame(lines=lines, ts=time.time()))
|
|
121
|
+
if not acc.is_text_unchanged(m):
|
|
122
|
+
rec = {
|
|
123
|
+
"ts": time.time(),
|
|
124
|
+
"mode": m.mode.value,
|
|
125
|
+
"page": m.page,
|
|
126
|
+
"anchor": m.anchor,
|
|
127
|
+
"scene": m.scene,
|
|
128
|
+
"overlap": m.overlap,
|
|
129
|
+
"text": m.text,
|
|
130
|
+
}
|
|
131
|
+
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
132
|
+
out.flush()
|
|
133
|
+
print(f"[{m.mode.value}] {m.page}/{m.anchor} {m.line_count} 行", file=sys.stderr)
|
|
134
|
+
time.sleep(a.interval)
|
|
135
|
+
except KeyboardInterrupt:
|
|
136
|
+
print("\n停止。", file=sys.stderr)
|
|
137
|
+
finally:
|
|
138
|
+
out.close()
|
|
139
|
+
sct.close()
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
144
|
+
p = argparse.ArgumentParser(prog="scrollbreaker", description="把滚动的屏幕切成能读的页")
|
|
145
|
+
p.add_argument("--version", action="version", version="scrollbreaker 0.1.0")
|
|
146
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
147
|
+
|
|
148
|
+
for name, help_text in (("replay", "离线回放多帧文本"), ("watch", "实时截屏采集")):
|
|
149
|
+
s = sub.add_parser(name, help=help_text)
|
|
150
|
+
s.add_argument("--window-s", type=float, default=None, help="同一次阅读的时间窗(秒)")
|
|
151
|
+
s.add_argument("--max-lines", type=int, default=None, help="单次累积行数上限")
|
|
152
|
+
s.add_argument("--min-overlap", type=int, default=None, help="认定同页所需最小重合行数")
|
|
153
|
+
if name == "replay":
|
|
154
|
+
s.add_argument("input", help="JSONL:每行 {\"ts\":..., \"lines\":[...]}")
|
|
155
|
+
s.set_defaults(func=cmd_replay)
|
|
156
|
+
else:
|
|
157
|
+
s.add_argument("--interval", type=float, default=20.0, help="截屏间隔(秒)")
|
|
158
|
+
s.add_argument("--monitor", type=int, default=1, help="显示器编号(mss)")
|
|
159
|
+
s.add_argument("--output", default="scrollbreaker.jsonl", help="输出 JSONL")
|
|
160
|
+
s.add_argument("--ocr-cmd", nargs="+", default=None,
|
|
161
|
+
help="OCR 命令行,{img} 占位。如: --ocr-cmd tesseract {img} stdout -l chi_sim+eng --psm 6")
|
|
162
|
+
s.set_defaults(func=cmd_watch)
|
|
163
|
+
return p
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv=None) -> int:
|
|
167
|
+
# Windows 控制台默认编码(cp936/cp1252)打印中文会直接抛 UnicodeEncodeError,
|
|
168
|
+
# 表现为 `scrollbreaker --version` 在 Windows 上崩掉、在 Linux/macOS 上正常。
|
|
169
|
+
# 在解析参数之前就把流改掉,因为 argparse 的 --version 是在 parse 时输出的。
|
|
170
|
+
for stream in (sys.stdout, sys.stderr):
|
|
171
|
+
try:
|
|
172
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
args = build_parser().parse_args(argv)
|
|
176
|
+
return args.func(args)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
raise SystemExit(main())
|
scrollbreaker/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""所有可调参数集中在这里。
|
|
2
|
+
|
|
3
|
+
这些默认值不是拍脑袋来的,是在真实的长文阅读 + 群聊滚动场景里调出来的:
|
|
4
|
+
|
|
5
|
+
ROLL_WINDOW_S = 180
|
|
6
|
+
同一次阅读最多算 3 分钟。再久就是「过了一会儿又看」,不该拼。
|
|
7
|
+
|
|
8
|
+
ROLL_MAX_LINES = 80
|
|
9
|
+
一次累积最多 80 行。再多上下文就膨胀,模型也读不动。
|
|
10
|
+
|
|
11
|
+
ROLL_MIN_OVERLAP = 2
|
|
12
|
+
至少 2 行完全一样,才认作「同一页在滚动」。1 行太容易误判——
|
|
13
|
+
两个不同页面里都可能出现「好的」「。
|
|
14
|
+
|
|
15
|
+
TIME_LIKE / STATUS_BAR
|
|
16
|
+
状态栏(网速、电量、时间)每屏都在变,既不能当页头,也不能当重合证据。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Pattern
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
# ── 累积闸门 ──────────────────────────────────────────────
|
|
27
|
+
window_s: float = 180.0 # 同一次阅读的时间窗(秒)
|
|
28
|
+
max_lines: int = 80 # 单次累积行数上限
|
|
29
|
+
min_overlap: int = 2 # 认定为「同一页」所需的最小重合行数
|
|
30
|
+
|
|
31
|
+
# ── 静屏去重 ──────────────────────────────────────────────
|
|
32
|
+
hash_bits: int = 64 # dHash 位数(64 = 8x8 差分)
|
|
33
|
+
same_bits: int = 6 # 汉明距离 ≤ 此值视为同一屏(不重复上报)
|
|
34
|
+
|
|
35
|
+
# ── 空白屏 ────────────────────────────────────────────────
|
|
36
|
+
blank_mean: float = 12.0 # 平均亮度低于此值 → 黑屏
|
|
37
|
+
blank_std: float = 3.0 # 亮度标准差低于此值 → 纯色屏
|
|
38
|
+
|
|
39
|
+
# ── 页面类型判定 ──────────────────────────────────────────
|
|
40
|
+
# 命中任一 → 认为这是「文章/长文」页(阅读类页面底部常驻这些按钮)
|
|
41
|
+
article_patterns: list[str] = field(default_factory=lambda: [
|
|
42
|
+
r"\+ ?关注", r"写留言", r"阅读原文", r"在看", r"点赞", r"分享到",
|
|
43
|
+
])
|
|
44
|
+
# 命中任一 → 认为这是「列表/会话列表」页
|
|
45
|
+
list_patterns: list[str] = field(default_factory=lambda: [
|
|
46
|
+
r"\[图片\]", r"\[语音\]", r"\[视频\]", r"\[文件\]",
|
|
47
|
+
r"折叠(的)?置顶", r"\[\d+条\]",
|
|
48
|
+
])
|
|
49
|
+
# 命中 → 这是一行「发言人: 内容」的聊天
|
|
50
|
+
chat_pattern: str = r"^\S{1,24}[::]\s*\S"
|
|
51
|
+
# 状态栏噪声行(网速/电量/时间),既不能当页头也不能当重合证据
|
|
52
|
+
status_pattern: str = (
|
|
53
|
+
r"^[\d\.\s]*(KB/S|M/s|MB/s|KB/s|G|%)?[\d\.\s]*$"
|
|
54
|
+
r"|^\d{1,2}[::]\d{2}$"
|
|
55
|
+
)
|
|
56
|
+
# 页头锚点保留长度:不同会话/不同文章前 N 个字通常就够区分
|
|
57
|
+
anchor_len: int = 14
|
|
58
|
+
# 文章页拼接时,前几行里找「第一行真正的正文」用的最小字数
|
|
59
|
+
body_min_chars: int = 12
|
|
60
|
+
body_scan_lines: int = 5
|
|
61
|
+
# 横幅行上限字数:短于这个且不含文章控件特征的行,判为「顶部通知横幅」("张三" / "在吗")
|
|
62
|
+
body_banner_max_chars: int = 8
|
|
63
|
+
|
|
64
|
+
# ── 归一化 ────────────────────────────────────────────────
|
|
65
|
+
# 整段文本归一化后与上一段相同 → 判为重复(只变了时间/电量)
|
|
66
|
+
collapse_ws: bool = True
|
|
67
|
+
max_text_chars: int = 4000
|
|
68
|
+
|
|
69
|
+
def compiled(self) -> "CompiledPatterns":
|
|
70
|
+
return CompiledPatterns(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class CompiledPatterns:
|
|
75
|
+
article: "Pattern[str]"
|
|
76
|
+
list_sig: "Pattern[str]"
|
|
77
|
+
chat: "Pattern[str]"
|
|
78
|
+
status: "Pattern[str]"
|
|
79
|
+
|
|
80
|
+
def __init__(self, cfg: Config):
|
|
81
|
+
joiner = "|"
|
|
82
|
+
self.article = re.compile(joiner.join(cfg.article_patterns)) if cfg.article_patterns else None
|
|
83
|
+
self.list_sig = re.compile(joiner.join(cfg.list_patterns)) if cfg.list_patterns else None
|
|
84
|
+
self.chat = re.compile(cfg.chat_pattern)
|
|
85
|
+
self.status = re.compile(cfg.status_pattern)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
DEFAULT_CONFIG = Config()
|
scrollbreaker/dedup.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""静屏去重:屏幕没动就别重复上报。
|
|
2
|
+
|
|
3
|
+
两种「没动」:
|
|
4
|
+
1. 内容完全一样 → dHash 汉明距离极小
|
|
5
|
+
2. 状态栏时间跳了一分钟,但正文一个字没变 → 整段归一化后完全一致
|
|
6
|
+
|
|
7
|
+
第一种在截图层挡掉(省 OCR),第二种在文本层挡掉(省 token)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
from typing import Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .config import Config, DEFAULT_CONFIG
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _to_gray(image_bytes: bytes):
|
|
19
|
+
"""把图片字节转成 9x8 灰度二维列表(dHash 只要这个)。"""
|
|
20
|
+
from PIL import Image # 延迟导入:只用文本链路的人不必装 Pillow
|
|
21
|
+
|
|
22
|
+
im = Image.open(io.BytesIO(image_bytes)).convert("L").resize((9, 8))
|
|
23
|
+
px = list(im.getdata())
|
|
24
|
+
return [px[i * 9:(i + 1) * 9] for i in range(8)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dhash(image_bytes: bytes) -> int:
|
|
28
|
+
"""差分哈希(dHash)。
|
|
29
|
+
|
|
30
|
+
做法:缩到 9x8,逐行比较相邻像素大小关系,得到 8x8=64 位。
|
|
31
|
+
相比 aHash,dHash 对整体亮度变化不敏感——状态栏变亮变暗不会误判成「新画面」。
|
|
32
|
+
"""
|
|
33
|
+
grid = _to_gray(image_bytes)
|
|
34
|
+
bits = 0
|
|
35
|
+
for row in grid:
|
|
36
|
+
for x in range(8):
|
|
37
|
+
bits = (bits << 1) | (1 if row[x] > row[x + 1] else 0)
|
|
38
|
+
return bits
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hamming(a: int, b: int) -> int:
|
|
42
|
+
"""两个哈希的差异位数。"""
|
|
43
|
+
return bin(a ^ b).count("1")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_duplicate(new_hash: int, prev_hash: Optional[int],
|
|
47
|
+
cfg: Config = DEFAULT_CONFIG) -> bool:
|
|
48
|
+
"""这一屏和上一屏是不是基本同一屏。"""
|
|
49
|
+
if prev_hash is None:
|
|
50
|
+
return False
|
|
51
|
+
return hamming(new_hash, prev_hash) <= cfg.same_bits
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_blank(image_bytes: bytes, cfg: Config = DEFAULT_CONFIG) -> tuple[bool, float, float]:
|
|
55
|
+
"""空白屏判定(息屏 / 纯色 / 全白)。
|
|
56
|
+
|
|
57
|
+
返回 (is_blank, mean, std)。
|
|
58
|
+
黑屏时 OCR 输出 0 段是**正常现象**,不能判为采集失败——早期版本把它
|
|
59
|
+
当错误处理,日志里全是不存在的「OCR 失败」。
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
from PIL import Image
|
|
63
|
+
|
|
64
|
+
im = Image.open(io.BytesIO(image_bytes)).convert("L").resize((48, 48))
|
|
65
|
+
px = list(im.getdata())
|
|
66
|
+
n = len(px)
|
|
67
|
+
mean = sum(px) / n
|
|
68
|
+
var = sum((p - mean) ** 2 for p in px) / n
|
|
69
|
+
std = var ** 0.5
|
|
70
|
+
return (mean < cfg.blank_mean or std < cfg.blank_std), mean, std
|
|
71
|
+
except Exception:
|
|
72
|
+
return False, -1.0, -1.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Deduper:
|
|
76
|
+
"""按场景记忆「最后一屏」,供循环里反复调用。"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, cfg: Config = DEFAULT_CONFIG):
|
|
79
|
+
self.cfg = cfg
|
|
80
|
+
self._last_px_hash: Optional[int] = None
|
|
81
|
+
self._last_norm: Optional[str] = None
|
|
82
|
+
|
|
83
|
+
def seen_pixels(self, image_bytes: bytes) -> tuple[bool, int]:
|
|
84
|
+
"""像素层去重:返回 (是否重复, 本次哈希)。"""
|
|
85
|
+
h = dhash(image_bytes)
|
|
86
|
+
dup = is_duplicate(h, self._last_px_hash, self.cfg)
|
|
87
|
+
self._last_px_hash = h
|
|
88
|
+
return dup, h
|
|
89
|
+
|
|
90
|
+
def seen_text(self, normalized: str) -> bool:
|
|
91
|
+
"""文本层去重:归一化后与上次完全一致即视为重复。"""
|
|
92
|
+
dup = normalized == self._last_norm
|
|
93
|
+
self._last_norm = normalized
|
|
94
|
+
return dup
|
|
95
|
+
|
|
96
|
+
def reset(self) -> None:
|
|
97
|
+
self._last_px_hash = None
|
|
98
|
+
self._last_norm = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dedup_lines(lines: Sequence[str]) -> list[str]:
|
|
102
|
+
"""同一屏内的完全重复行只保留第一次出现(保序)。"""
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
out: list[str] = []
|
|
105
|
+
for l in lines:
|
|
106
|
+
if l in seen:
|
|
107
|
+
continue
|
|
108
|
+
seen.add(l)
|
|
109
|
+
out.append(l)
|
|
110
|
+
return out
|
scrollbreaker/ocr.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""OCR 适配层:本项目不绑定任何 OCR 引擎。
|
|
2
|
+
|
|
3
|
+
设计原则:**OCR 是可替换的**。滚动分页逻辑才是这个项目的价值,
|
|
4
|
+
所以这里只定义一个极小的协议,你想用哪个引擎都行。
|
|
5
|
+
|
|
6
|
+
自带三种:
|
|
7
|
+
NullOCR —— 不做 OCR,直接把传入的文本当结果(写测试/离线回放用)
|
|
8
|
+
CmdOCR —— 调任意命令行 OCR(tesseract / paddleocr-cli / 自建服务)
|
|
9
|
+
CallableOCR —— 传一个 Python 函数进来
|
|
10
|
+
|
|
11
|
+
如果你的引擎返回带坐标的结果,按行分组后传进来即可 —— 本项目只关心「行」。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable, Protocol, Sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OCR(Protocol):
|
|
24
|
+
def read(self, image_bytes: bytes) -> str: # pragma: no cover - protocol
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NullOCR:
|
|
29
|
+
"""不识别,返回空串(或一个固定文本,便于测试)。"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, fixed: str = ""):
|
|
32
|
+
self.fixed = fixed
|
|
33
|
+
|
|
34
|
+
def read(self, image_bytes: bytes) -> str:
|
|
35
|
+
return self.fixed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CallableOCR:
|
|
39
|
+
"""包装一个 (bytes) -> str 的函数。"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, fn: Callable[[bytes], str]):
|
|
42
|
+
self.fn = fn
|
|
43
|
+
|
|
44
|
+
def read(self, image_bytes: bytes) -> str:
|
|
45
|
+
return self.fn(image_bytes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CmdOCR:
|
|
49
|
+
"""调命令行 OCR。
|
|
50
|
+
|
|
51
|
+
tesseract 例子:
|
|
52
|
+
CmdOCR(["tesseract", "{img}", "stdout", "-l", "chi_sim+eng", "--psm", "6"])
|
|
53
|
+
|
|
54
|
+
`{img}` 会被替换成临时图片路径。输出里的行顺序即屏幕顺序。
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, argv: Sequence[str], timeout: float = 30.0, cleanup: bool = True):
|
|
58
|
+
self.argv = list(argv)
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.cleanup = cleanup
|
|
61
|
+
|
|
62
|
+
def available(self) -> bool:
|
|
63
|
+
if not self.argv:
|
|
64
|
+
return False
|
|
65
|
+
if shutil.which(self.argv[0]) is None:
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def read(self, image_bytes: bytes) -> str:
|
|
70
|
+
if not self.available():
|
|
71
|
+
raise RuntimeError(f"OCR 可执行文件不存在: {self.argv[0] if self.argv else '(empty)'}")
|
|
72
|
+
suffix = ".png"
|
|
73
|
+
if image_bytes[:2] == b"\xff\xd8":
|
|
74
|
+
suffix = ".jpg"
|
|
75
|
+
fd, path = tempfile.mkstemp(suffix=suffix)
|
|
76
|
+
try:
|
|
77
|
+
with open(fd, "wb") as f:
|
|
78
|
+
f.write(image_bytes)
|
|
79
|
+
argv = [a.replace("{img}", path) for a in self.argv]
|
|
80
|
+
out = subprocess.run(argv, capture_output=True, timeout=self.timeout)
|
|
81
|
+
if out.returncode != 0:
|
|
82
|
+
raise RuntimeError(out.stderr.decode("utf-8", "ignore")[:300])
|
|
83
|
+
return out.stdout.decode("utf-8", "ignore")
|
|
84
|
+
finally:
|
|
85
|
+
if self.cleanup:
|
|
86
|
+
Path(path).unlink(missing_ok=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tesseract(lang: str = "chi_sim+eng") -> CmdOCR:
|
|
90
|
+
"""tesseract 的常用配置(psm 6 = 假设一列文本,屏幕识别通常最稳)。"""
|
|
91
|
+
return CmdOCR(["tesseract", "{img}", "stdout", "-l", lang, "--psm", "6"])
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""页面类型判定:这一屏到底是什么?
|
|
2
|
+
|
|
3
|
+
为什么必须先判类型:
|
|
4
|
+
只看「两屏有没有重合行」是不够的。实测中,公众号文章和聊天窗口之间
|
|
5
|
+
出现过 6 行完全相同的行(都是「正常」「好的」这类字),按重合拼就
|
|
6
|
+
把一篇文章和一段聊天糊成了一条记录——数据彻底废掉。
|
|
7
|
+
|
|
8
|
+
所以先给每一屏定性,只有**同类型**的两屏才可能被拼到一起。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import string
|
|
12
|
+
from typing import Iterable, Sequence
|
|
13
|
+
|
|
14
|
+
from .config import CompiledPatterns, Config, DEFAULT_CONFIG
|
|
15
|
+
|
|
16
|
+
# 页面类型
|
|
17
|
+
ARTICLE = "article" # 文章/长文正文页
|
|
18
|
+
CHAT = "chat" # 单聊/群聊会话页
|
|
19
|
+
LIST = "list" # 会话列表 / 消息列表
|
|
20
|
+
TEXT = "text" # 其他(设置页、纯文本、表单…)
|
|
21
|
+
MIXED = "mixed" # 一屏里同时有文章特征和聊天/列表特征
|
|
22
|
+
|
|
23
|
+
#: 可以参与累积的类型。mixed 是「切换过程中的混合画面」,既不能当起点也不能被吸收。
|
|
24
|
+
ACCUMULABLE = (ARTICLE, CHAT, LIST, TEXT)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def page_type(lines: Sequence[str], cfg: Config = DEFAULT_CONFIG,
|
|
28
|
+
pats: CompiledPatterns | None = None) -> str:
|
|
29
|
+
"""给一屏文本定性。
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
lines: 已按屏幕顺序切好的文本行
|
|
33
|
+
cfg: 配置(阈值都在里面)
|
|
34
|
+
pats: 预编译的正则(循环里复用,别每帧重编译)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
ARTICLE / CHAT / LIST / TEXT / MIXED
|
|
38
|
+
"""
|
|
39
|
+
p = pats or cfg.compiled()
|
|
40
|
+
chat = sum(1 for l in lines if p.chat.match(l))
|
|
41
|
+
art = sum(1 for l in lines if p.article and p.article.search(l))
|
|
42
|
+
lst = sum(1 for l in lines if p.list_sig and p.list_sig.search(l))
|
|
43
|
+
|
|
44
|
+
# 同时具备两类特征 → 这屏本身就是切页中间态,判 mixed 单独处理
|
|
45
|
+
if art and (chat >= 2 or lst >= 2):
|
|
46
|
+
return MIXED
|
|
47
|
+
if art:
|
|
48
|
+
return ARTICLE
|
|
49
|
+
if chat >= 2:
|
|
50
|
+
return CHAT
|
|
51
|
+
if lst:
|
|
52
|
+
return LIST
|
|
53
|
+
return TEXT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def head_anchor(lines: Sequence[str], cfg: Config = DEFAULT_CONFIG,
|
|
57
|
+
pats: CompiledPatterns | None = None) -> str:
|
|
58
|
+
"""页头锚点:当前页的「身份」。
|
|
59
|
+
|
|
60
|
+
聊天页的第一行通常是联系人名,文章页的第一行通常是标题,
|
|
61
|
+
正文页的第一行通常是文章名。取它做锚点,锚点变了 = 换页了。
|
|
62
|
+
|
|
63
|
+
尾部数字要剥掉:未读数是「张三 3」→「张三 12」,数字变了但人没变。
|
|
64
|
+
"""
|
|
65
|
+
p = pats or cfg.compiled()
|
|
66
|
+
for line in lines[:2]:
|
|
67
|
+
s = line.strip()
|
|
68
|
+
if not s or p.status.match(s):
|
|
69
|
+
continue
|
|
70
|
+
# 去掉尾部数字(未读数 / 计数)
|
|
71
|
+
stripped = s.rstrip(string.digits + " ")
|
|
72
|
+
return stripped[: cfg.anchor_len]
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def body_lines(lines: Sequence[str], cfg: Config = DEFAULT_CONFIG) -> Sequence[str]:
|
|
77
|
+
"""文章页取正文:跳过顶部那条「新消息横幅」。
|
|
78
|
+
|
|
79
|
+
很多应用在阅读文章时会在顶部浮一条消息通知(「某某: 在吗」)。
|
|
80
|
+
它会被 OCR 抓到正文前面,拼进去就成了「文章里夹着聊天」。
|
|
81
|
+
做法:在前几行里找第一行足够长的(≥ body_min_chars),从那行开始算正文。
|
|
82
|
+
找不到足够长的行时,退一步只剥掉那些「明显是横幅」的行(短行 + 聊天特征)。
|
|
83
|
+
"""
|
|
84
|
+
head = lines[: cfg.body_scan_lines]
|
|
85
|
+
pats = cfg.compiled()
|
|
86
|
+
for i, line in enumerate(head):
|
|
87
|
+
s = line.strip()
|
|
88
|
+
if len(s) < cfg.body_min_chars:
|
|
89
|
+
continue
|
|
90
|
+
# 控件行(+关注 / 写留言 / 点赞…)不是正文,别拿它当正文起点
|
|
91
|
+
if pats.article and pats.article.search(s):
|
|
92
|
+
continue
|
|
93
|
+
return lines[i:]
|
|
94
|
+
# 全屏都是短行:剥掉确实像「通知横幅」的那些(2~8 字、非控件行),其余保留。
|
|
95
|
+
# 阈值故意卡在 8 字:真实文章正文行一般 15~30 字,不会这么短;
|
|
96
|
+
# 而横幅就是「张三」「在吗」这种 2~3 个字。
|
|
97
|
+
cut = 0
|
|
98
|
+
for i, line in enumerate(head):
|
|
99
|
+
s = line.strip()
|
|
100
|
+
is_banner_like = (
|
|
101
|
+
len(s) <= cfg.body_banner_max_chars
|
|
102
|
+
and not (pats.article and pats.article.search(s))
|
|
103
|
+
)
|
|
104
|
+
if is_banner_like:
|
|
105
|
+
cut = i + 1
|
|
106
|
+
continue
|
|
107
|
+
break
|
|
108
|
+
return lines[cut:] if cut else lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def chat_anchor(lines: Sequence[str], cfg: Config = DEFAULT_CONFIG,
|
|
112
|
+
pats: CompiledPatterns | None = None) -> str:
|
|
113
|
+
"""聊天页的「同一会话」判据。
|
|
114
|
+
|
|
115
|
+
不能只看第一行:聊天页滚动时,第一行是**第一条消息**会变,页头(联系人名)
|
|
116
|
+
可能被滚出屏幕。实测里两种页面都存在,所以做一个鲁棒判据:
|
|
117
|
+
|
|
118
|
+
1. 若第一行不是「发言人: 内容」形态 → 它就是页头,直接用(最常见)
|
|
119
|
+
2. 否则 → 用本屏出现过的发言人集合(排序后拼接),同一会话的发言人集合稳定
|
|
120
|
+
|
|
121
|
+
第 2 种在「页头滚出屏幕」时特别有用:不同会话的发言人集合几乎不会重合。
|
|
122
|
+
"""
|
|
123
|
+
p = pats or cfg.compiled()
|
|
124
|
+
for line in lines[:2]:
|
|
125
|
+
s = line.strip()
|
|
126
|
+
if not s or p.status.match(s):
|
|
127
|
+
continue
|
|
128
|
+
if not p.chat.match(s):
|
|
129
|
+
return s.rstrip(string.digits + " ")[: cfg.anchor_len]
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
speakers: list[str] = []
|
|
133
|
+
for line in lines:
|
|
134
|
+
m = p.chat.match(line)
|
|
135
|
+
if not m:
|
|
136
|
+
continue
|
|
137
|
+
name = line.split(":", 1)[0].split(":", 1)[0].strip()
|
|
138
|
+
if name and name not in speakers:
|
|
139
|
+
speakers.append(name)
|
|
140
|
+
return "|".join(sorted(speakers)[:4])[: cfg.anchor_len * 2]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def normalize(text: str, cfg: Config = DEFAULT_CONFIG) -> str:
|
|
145
|
+
"""整段归一化,用于判「内容一个字都没变」。"""
|
|
146
|
+
if not cfg.collapse_ws:
|
|
147
|
+
return text
|
|
148
|
+
return " ".join(text.split())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def clean_lines(raw: str, min_len: int = 1) -> list[str]:
|
|
152
|
+
"""把 OCR 出来的整段文本切成「有意义」的行:去空行、去纯符号、去重后再返回。
|
|
153
|
+
|
|
154
|
+
注意:这里**不做跨屏去重**,跨屏去重是 Accumulator 的活——
|
|
155
|
+
同一屏里重复出现的行(如列表里的分隔符)保留原位更利于比对。
|
|
156
|
+
"""
|
|
157
|
+
out: list[str] = []
|
|
158
|
+
for line in raw.splitlines():
|
|
159
|
+
s = line.strip()
|
|
160
|
+
if len(s) < min_len:
|
|
161
|
+
continue
|
|
162
|
+
if all(not ch.isalnum() for ch in s): # 纯标点/纯符号行
|
|
163
|
+
continue
|
|
164
|
+
out.append(s)
|
|
165
|
+
return out
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrollbreaker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scroll-aware screen content collector for LLM agents — 把滚动的屏幕切成能读的页
|
|
5
|
+
Author: scrollbreaker contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ocr,screen,scroll,llm,agent,capture,information-collector,content-collector
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Text Processing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: image
|
|
17
|
+
Requires-Dist: pillow>=9; extra == "image"
|
|
18
|
+
Requires-Dist: mss>=9; extra == "image"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# ScrollBreaker
|
|
24
|
+
|
|
25
|
+
**A scroll-aware screen content collector for LLM agents** — 一个会分辨页面的信息采集器。
|
|
26
|
+
|
|
27
|
+
> **Your LLM is eating garbage. The screenshots are the problem.**
|
|
28
|
+
>
|
|
29
|
+
> Every scroll-capture tool on the planet splices frames together by line overlap.
|
|
30
|
+
> That works — until it silently fuses an article and a chat log into one record
|
|
31
|
+
> that looks perfectly normal. ScrollBreaker stops that with **four gates**.
|
|
32
|
+
|
|
33
|
+
Most **information collectors** answer the question *"how do I grab the screen?"*
|
|
34
|
+
ScrollBreaker answers the harder one: *"are these two grabs the same page at all?"*
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Screenshot → OCR → merge is the standard pipeline for feeding visual content to an
|
|
38
|
+
agent. It has one failure mode that poisons your dataset and never warns you:
|
|
39
|
+
**unrelated pages that happen to share lines.**
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Article page : 正常 / 好的 / 收到 / 在看 / +关注 / body paragraph...
|
|
43
|
+
Chat page : 正常 / 好的 / 收到 / Alice: stop machine 4 / Bob: on it
|
|
44
|
+
Shared lines : 3
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Any tool that merges on "overlap ≥ 2" fuses these two. The result is one record
|
|
48
|
+
containing an article with a factory conversation stapled into its middle. It parses
|
|
49
|
+
fine. It embeds fine. It is **wrong**, and nothing downstream will tell you.
|
|
50
|
+
|
|
51
|
+
ScrollBreaker refuses to merge unless four gates all pass.
|
|
52
|
+
|
|
53
|
+
## The four gates
|
|
54
|
+
|
|
55
|
+
| # | Gate | Rule | Default |
|
|
56
|
+
|---|------|------|---------|
|
|
57
|
+
| 1 | Time window | Frames further apart than N seconds are a different reading session | `180s` |
|
|
58
|
+
| 2 | Same type | `article` / `chat` / `list` / `text` must match | — |
|
|
59
|
+
| 3 | Same anchor | Same page header (relaxed for articles — titles scroll away) | — |
|
|
60
|
+
| 4 | Real overlap | ≥ N **identical** lines, because 1 line is noise | `2` |
|
|
61
|
+
|
|
62
|
+
Fail any one → **new record, no merge. Ever.**
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
No PyPI account needed — the wheel is attached to the release:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install https://github.com/xiangjianhuan007/scrollbreaker/releases/download/v0.1.0/scrollbreaker-0.1.0-py3-none-any.whl
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or from source, if you want to poke at it:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/xiangjianhuan007/scrollbreaker
|
|
76
|
+
cd scrollbreaker
|
|
77
|
+
pip install -e ".[dev]"
|
|
78
|
+
pytest -q
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Verify the install the same way CI does:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
scrollbreaker --version
|
|
85
|
+
scrollbreaker replay examples/frames.jsonl
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Zero runtime dependencies. The `image` extra (`pillow`, `mss`) is only needed if you
|
|
89
|
+
want to feed it real screenshots instead of OCR'd text.
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
There is a fifth state, `mixed`: a frame containing both article and chat/list
|
|
93
|
+
signatures. That frame *is* a page transition in progress, so it can neither start
|
|
94
|
+
an accumulation nor be absorbed into one.
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install -e .
|
|
100
|
+
# for live capture: pip install mss pillow
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from scrollbreaker import Accumulator, Frame, Mode
|
|
105
|
+
|
|
106
|
+
acc = Accumulator() # every threshold lives in Config
|
|
107
|
+
|
|
108
|
+
for snap in screenshots: # one frame every N seconds
|
|
109
|
+
m = acc.push(Frame(lines=snap.lines, ts=snap.ts))
|
|
110
|
+
|
|
111
|
+
if m.mode is Mode.ROLL:
|
|
112
|
+
pass # same page scrolling; m.text is the full text
|
|
113
|
+
else:
|
|
114
|
+
handle_page(m.text) # page changed; previous record is complete
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Every decision is auditable — `m.gate_log` tells you which gate failed:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
{"time_window": True, "same_type": False, "same_anchor": False,
|
|
121
|
+
"anchor_relaxed": True, "overlap_enough": False}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### CLI
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# offline replay — tune thresholds without capturing anything
|
|
128
|
+
scrollbreaker replay examples/frames.jsonl
|
|
129
|
+
|
|
130
|
+
# live capture
|
|
131
|
+
scrollbreaker watch --interval 10 \
|
|
132
|
+
--ocr-cmd tesseract {img} stdout -l eng --psm 6 \
|
|
133
|
+
--output records.jsonl
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Real output from `examples/frames.jsonl`:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
frame 1 new chat anchor=Alice|Bob
|
|
140
|
+
frame 2 roll chat anchor=Alice|Bob overlap=2 <- same conversation, merged
|
|
141
|
+
frame 3 new article same_type=false <- switched pages, hard break
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
That third line is the whole point: **wrong type, no merge — no matter the overlap.**
|
|
145
|
+
|
|
146
|
+
## Android capture app (optional)
|
|
147
|
+
|
|
148
|
+
`android/` contains the phone half of the pair: interval screen capture → dHash
|
|
149
|
+
dedup → refuse password/payment screens → POST the frame to an endpoint you control.
|
|
150
|
+
|
|
151
|
+
It is source, not a shipped app — you build it yourself, and **nothing leaves the
|
|
152
|
+
device until you enter an endpoint**. Requires Android 11+ (API 30) because
|
|
153
|
+
`AccessibilityService.takeScreenshot()` is the only unattended capture API.
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
android/ 769 lines Kotlin, zero dependencies
|
|
157
|
+
CaptureService.kt the loop + the four gates + the privacy gate
|
|
158
|
+
ImageSig.kt dHash + blank detection
|
|
159
|
+
Uplink.kt one POST, no SDK
|
|
160
|
+
CaptureConfig.kt everything configurable, nothing hard-coded
|
|
161
|
+
MainActivity.kt settings screen (built in code, no layouts)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
See [android/README.md](android/README.md) for the permission trade-off, the payload
|
|
165
|
+
shape and a 10-line receiver.
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
## Output shape
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{"ts": 1000.0, "mode": "roll", "page": "chat", "anchor": "Alice|Bob",
|
|
172
|
+
"scene": "chat|Alice|Bob", "overlap": 2, "text": "..."}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`scene` is the field that matters downstream. Group by `scene` when you store,
|
|
176
|
+
embed, or summarize, and unrelated captures can no longer contaminate each other.
|
|
177
|
+
|
|
178
|
+
## Design trade-offs
|
|
179
|
+
|
|
180
|
+
**OCR is a plug-in.** The whole contract is `read(bytes) -> str`. Use tesseract,
|
|
181
|
+
PaddleOCR, a cloud API, a local VLM — ScrollBreaker never looks at pixels, only lines.
|
|
182
|
+
|
|
183
|
+
**Screen only, no database spelunking.** No file parsing, no process injection,
|
|
184
|
+
no root. You lose some fidelity versus reading the source directly; you gain
|
|
185
|
+
application-agnostic capture that survives app updates.
|
|
186
|
+
|
|
187
|
+
**Thresholds are empirical, not laws.** `180s / 80 lines / 2 overlap` came from real
|
|
188
|
+
long-form reading and group-chat scrolling. Monitoring a dashboard or a log tail?
|
|
189
|
+
Change `Config`.
|
|
190
|
+
|
|
191
|
+
**Chat anchors degrade gracefully.** A chat header scrolls out of view, at which point
|
|
192
|
+
the first line is a message, not a header. ScrollBreaker falls back to the set of
|
|
193
|
+
speakers seen in the frame — stable within a conversation, rarely shared across two.
|
|
194
|
+
|
|
195
|
+
## Test suite
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
pip install -e ".[dev]" && pytest -q
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
9 tests covering each gate. The one that matters is `test_article_to_chat_never_merged`:
|
|
202
|
+
two frames sharing **6 lines** with different page types must still produce two records.
|
|
203
|
+
It exists because that exact fusion happened and corrupted real data. Run it before
|
|
204
|
+
touching the merge logic.
|
|
205
|
+
|
|
206
|
+
## Compliance
|
|
207
|
+
|
|
208
|
+
- Reads **only what is visible on screen**. No application data files, no databases,
|
|
209
|
+
no process memory.
|
|
210
|
+
- **No network, no telemetry.** Everything stays local; output is a JSONL on your disk.
|
|
211
|
+
- Capturing other people's screens, or redistributing captured content, may violate
|
|
212
|
+
local law and the target platform's terms. **Use it on your own device, your own
|
|
213
|
+
account, your own visible content.**
|
|
214
|
+
- This project provides no authentication bypass, no database decryption, and no
|
|
215
|
+
process injection — and will not accept PRs that add them.
|
|
216
|
+
|
|
217
|
+
## 中文说明
|
|
218
|
+
|
|
219
|
+
**ScrollBreaker(破滚者)** —— 把滚动的屏幕切成能读的页。
|
|
220
|
+
|
|
221
|
+
核心不是「截图 + OCR」,是**判断两屏到底是不是同一页**。绝大多数采集工具按「有
|
|
222
|
+
两行重合」就拼,于是把一篇文章和一段聊天糊成一条记录——数据被污染了还看不出来。
|
|
223
|
+
|
|
224
|
+
四道闸门(时间窗 / 类型一致 / 页头一致 / 实质重合)全部通过才允许合并,任一条不过
|
|
225
|
+
就果断断开。`mixed`(一屏里同时出现文章和聊天特征)是切页中间态,既不累积也不被吸收。
|
|
226
|
+
|
|
227
|
+
阈值都在 `Config` 里,改成你的场景就行。
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT — do whatever you want, just don't blame us when your OCR is worse than ours.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
scrollbreaker/__init__.py,sha256=KNIbn7DHmbK66eCRTwKn8yAbY3R2NY2ByQD-SWnDhqg,1066
|
|
2
|
+
scrollbreaker/accumulate.py,sha256=8UyCMQQtKzRWrPpMRqTZYEfvBt8ktU9q8jKRm1DBGBQ,7668
|
|
3
|
+
scrollbreaker/cli.py,sha256=vhJCxQ8tgyK9piZO6YXoBmygimSLKM8MVIm5RVhgOHY,6525
|
|
4
|
+
scrollbreaker/config.py,sha256=KQunXF7pNo0J-9_MxRxEGPWXzxLi5xbHAKKUEHLjy0Q,4211
|
|
5
|
+
scrollbreaker/dedup.py,sha256=NxAb0B2K26ofDYcy3CqnBk0pn4Q8WIBTTZbDo_VG-FM,3610
|
|
6
|
+
scrollbreaker/ocr.py,sha256=A_temluosAxBeZfNVvsH8E1rKEf9QZkoxn8lcHQcDzc,3023
|
|
7
|
+
scrollbreaker/pagetype.py,sha256=wdCxIzO761TBnp0DQdif97_Hcs6kmQP_baORnrab2Zc,6472
|
|
8
|
+
scrollbreaker-0.1.0.dist-info/licenses/LICENSE,sha256=qY3DTT14F8rlKSEgExvukmGV3JNUIE3DTJXDoCqOby8,1079
|
|
9
|
+
scrollbreaker-0.1.0.dist-info/METADATA,sha256=BML79-TO6T61iTKByVVfkGl_aPrhjxmOIyGdU8sN4ik,8534
|
|
10
|
+
scrollbreaker-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
scrollbreaker-0.1.0.dist-info/entry_points.txt,sha256=TagwEjxjvLdsw8_14MGBrnBQcNkal-D7bkxxlbs76Z0,57
|
|
12
|
+
scrollbreaker-0.1.0.dist-info/top_level.txt,sha256=yUPq6epfsCMSlTaVpZhYT9OZjNMV5WKjFuWVrN-ViyY,14
|
|
13
|
+
scrollbreaker-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 scrollcap contributors
|
|
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
|
+
scrollbreaker
|