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.
@@ -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())
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scrollbreaker = scrollbreaker.cli:main
@@ -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