astrocrawl 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.
Files changed (124) hide show
  1. astrocrawl/__init__.py +7 -0
  2. astrocrawl/__main__.py +6 -0
  3. astrocrawl/_constants.py +376 -0
  4. astrocrawl/_json_compat.py +26 -0
  5. astrocrawl/_packaged.py +54 -0
  6. astrocrawl/_path_strategy.py +148 -0
  7. astrocrawl/_retry_strategy.py +64 -0
  8. astrocrawl/_startup.py +99 -0
  9. astrocrawl/_types.py +255 -0
  10. astrocrawl/_version.py +6 -0
  11. astrocrawl/ai/__init__.py +88 -0
  12. astrocrawl/ai/_client.py +426 -0
  13. astrocrawl/ai/_config.py +146 -0
  14. astrocrawl/ai/_constraint.py +23 -0
  15. astrocrawl/ai/_errors.py +61 -0
  16. astrocrawl/ai/_observability.py +69 -0
  17. astrocrawl/ai/_profile.py +61 -0
  18. astrocrawl/ai/_provider.py +88 -0
  19. astrocrawl/ai/_provider_registry.py +92 -0
  20. astrocrawl/ai/_rate_limiter.py +152 -0
  21. astrocrawl/ai/_types.py +121 -0
  22. astrocrawl/ai/_usage_tracker.py +48 -0
  23. astrocrawl/ai/providers/__init__.py +7 -0
  24. astrocrawl/ai/providers/anthropic.py +333 -0
  25. astrocrawl/ai/providers/google.py +298 -0
  26. astrocrawl/ai/providers/openai.py +427 -0
  27. astrocrawl/browser/__init__.py +5 -0
  28. astrocrawl/browser/_device_caps.py +23 -0
  29. astrocrawl/browser/_domain_memory.py +54 -0
  30. astrocrawl/browser/_preview.py +299 -0
  31. astrocrawl/browser/_preview_inject.js +480 -0
  32. astrocrawl/browser/_retry.py +41 -0
  33. astrocrawl/browser/_slot_pool.py +279 -0
  34. astrocrawl/browser/browser_pool.py +848 -0
  35. astrocrawl/browser/context_pool.py +179 -0
  36. astrocrawl/browser/navigation.py +38 -0
  37. astrocrawl/browser/page_pool.py +79 -0
  38. astrocrawl/cli/__init__.py +5 -0
  39. astrocrawl/cli/_i18n.py +58 -0
  40. astrocrawl/cli/main.py +1771 -0
  41. astrocrawl/config.py +348 -0
  42. astrocrawl/crawler/__init__.py +16 -0
  43. astrocrawl/crawler/_url_gate.py +82 -0
  44. astrocrawl/crawler/engine.py +2148 -0
  45. astrocrawl/crawler/liveness.py +53 -0
  46. astrocrawl/crawler/outcomes.py +381 -0
  47. astrocrawl/crawler/progress.py +204 -0
  48. astrocrawl/crawler/signals.py +89 -0
  49. astrocrawl/crawler/supervisors.py +112 -0
  50. astrocrawl/diagnostics.py +256 -0
  51. astrocrawl/gui/__init__.py +1 -0
  52. astrocrawl/gui/_ai_profile_page.py +750 -0
  53. astrocrawl/gui/_animated_bar.py +206 -0
  54. astrocrawl/gui/_delegates.py +132 -0
  55. astrocrawl/gui/_i18n.py +70 -0
  56. astrocrawl/gui/_preview_panel.py +455 -0
  57. astrocrawl/gui/_preview_session.py +209 -0
  58. astrocrawl/gui/_proxy_endpoint_dialog.py +215 -0
  59. astrocrawl/gui/_proxy_profile_page.py +861 -0
  60. astrocrawl/gui/_route_settings_page.py +347 -0
  61. astrocrawl/gui/_style.py +105 -0
  62. astrocrawl/gui/_table_page.py +228 -0
  63. astrocrawl/gui/_tokens.py +40 -0
  64. astrocrawl/gui/advanced_dialog.py +611 -0
  65. astrocrawl/gui/completion_dialog.py +194 -0
  66. astrocrawl/gui/crawl_session.py +211 -0
  67. astrocrawl/gui/main_window.py +967 -0
  68. astrocrawl/gui/proxy_health_bar.py +159 -0
  69. astrocrawl/gui/rules_dialog.py +2798 -0
  70. astrocrawl/gui/theme.py +176 -0
  71. astrocrawl/gui/theme_dialog.py +279 -0
  72. astrocrawl/gui/thread.py +80 -0
  73. astrocrawl/gui/title_bar.py +61 -0
  74. astrocrawl/gui/translations/astrocrawl_gui_zh_CN.qm +0 -0
  75. astrocrawl/gui/worker_status_bar.py +76 -0
  76. astrocrawl/health.py +58 -0
  77. astrocrawl/health_monitor.py +175 -0
  78. astrocrawl/main.py +107 -0
  79. astrocrawl/network/__init__.py +16 -0
  80. astrocrawl/network/_fetch.py +152 -0
  81. astrocrawl/network/robots.py +319 -0
  82. astrocrawl/network/sitemap.py +554 -0
  83. astrocrawl/network/throttling.py +206 -0
  84. astrocrawl/proxy/__init__.py +57 -0
  85. astrocrawl/proxy/_config.py +292 -0
  86. astrocrawl/proxy/_consumers.py +15 -0
  87. astrocrawl/proxy/_hook.py +44 -0
  88. astrocrawl/proxy/_probe.py +46 -0
  89. astrocrawl/proxy/_proxy.py +451 -0
  90. astrocrawl/proxy/_session.py +232 -0
  91. astrocrawl/resilience.py +107 -0
  92. astrocrawl/rules/__init__.py +104 -0
  93. astrocrawl/rules/_ai.py +294 -0
  94. astrocrawl/rules/_chatml.py +47 -0
  95. astrocrawl/rules/_extractor.py +274 -0
  96. astrocrawl/rules/_html_preprocess.py +103 -0
  97. astrocrawl/rules/_io.py +239 -0
  98. astrocrawl/rules/_lifecycle.py +170 -0
  99. astrocrawl/rules/_loader.py +389 -0
  100. astrocrawl/rules/_markdown.py +25 -0
  101. astrocrawl/rules/_matcher.py +157 -0
  102. astrocrawl/rules/_prompt_template_position.txt +42 -0
  103. astrocrawl/rules/_prompt_template_type.txt +114 -0
  104. astrocrawl/rules/_schema.py +471 -0
  105. astrocrawl/rules/_source.py +652 -0
  106. astrocrawl/rules/_state.py +171 -0
  107. astrocrawl/rules/_template.py +292 -0
  108. astrocrawl/rules/_transform.py +122 -0
  109. astrocrawl/storage/__init__.py +21 -0
  110. astrocrawl/storage/_protocol.py +89 -0
  111. astrocrawl/storage/db.py +728 -0
  112. astrocrawl/storage/writer.py +117 -0
  113. astrocrawl/utils/__init__.py +17 -0
  114. astrocrawl/utils/_atomic.py +61 -0
  115. astrocrawl/utils/html.py +227 -0
  116. astrocrawl/utils/logging.py +106 -0
  117. astrocrawl/utils/preferences.py +579 -0
  118. astrocrawl/utils/url.py +118 -0
  119. astrocrawl-0.1.0.dist-info/METADATA +1327 -0
  120. astrocrawl-0.1.0.dist-info/RECORD +124 -0
  121. astrocrawl-0.1.0.dist-info/WHEEL +5 -0
  122. astrocrawl-0.1.0.dist-info/entry_points.txt +7 -0
  123. astrocrawl-0.1.0.dist-info/licenses/LICENSE +202 -0
  124. astrocrawl-0.1.0.dist-info/top_level.txt +1 -0
astrocrawl/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from astrocrawl._version import __version__, __version_info__
4
+ from astrocrawl.config import DEFAULT_CONFIG, CrawlerConfig
5
+ from astrocrawl.crawler.engine import AsyncCrawler
6
+
7
+ __all__ = ["__version__", "__version_info__", "CrawlerConfig", "AsyncCrawler", "DEFAULT_CONFIG"]
astrocrawl/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from astrocrawl.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ # ── 解析规则引擎 ──────────────────────────────────────────────────────────────
4
+ # 使用者: rules/_schema.py / rules/_loader.py / rules/_extractor.py
5
+ MAX_RULES_TOTAL = 2000 # N11: 规则总数上限
6
+ MAX_FALLBACK_DEPTH = 3 # S22: fallback 链最大深度(主 + 2 层)
7
+ MAX_FIELDS_PER_RULE = 50 # 单规则最大字段数
8
+ MAX_FIELD_NAME_LENGTH = 64 # 字段名最大长度
9
+ MULTIPLE_MAX_ITEMS = 1000 # S26: multiple:true 最大返回项数
10
+ SELECTOR_TIMEOUT_PER_RULE = 5 # N18: 单规则选择器执行超时 (秒)
11
+ MAX_RULE_FILE_SIZE = 64 * 1024 # S14: 单规则文件最大 64KB
12
+ MAX_RULES_CACHE_SIZE = 50 * 1024 * 1024 # S16: 规则缓存总量 ≤ 50MB
13
+ MAX_JSON_DEPTH = 20 # S24: JSON 最大嵌套深度
14
+ RULES_TMP_MAX_AGE_HOURS = 24 # S15: 过期 .tmp 文件清理阈值 (小时)
15
+ SOURCE_PRIORITY = {"pip": 0, "remote": 1, "user": 2} # 规则源优先级,使用者: rules/_loader.py rules/_matcher.py
16
+
17
+ # ── 远程规则源 ──────────────────────────────────────────────────────────────
18
+ # 使用者: rules/_source.py SourceManager
19
+ MANIFEST_MAX_BYTES = 1 * 1024 * 1024 # S10: Manifest 响应体最大 1MB
20
+ SOURCE_DOWNLOAD_TIMEOUT = 30 # S08: 下载超时 (秒)
21
+ SOURCE_DAILY_UPDATE_LIMIT = 12 # N9: 单源每日更新次数上限
22
+ DOWNLOAD_CONCURRENCY_GLOBAL = 5 # S38: 全局下载并发上限
23
+ DOWNLOAD_CONCURRENCY_PER_SOURCE = 2 # S38: 单源下载并发上限
24
+ MAX_REDIRECTS = 5 # S03: HTTP 重定向最大次数
25
+ MAX_SOURCE_URL_LENGTH = 2048 # URL 长度上限
26
+ SOURCE_DEGRADED_COOLDOWN = 600 # M2: Degraded 源冷却时间 (秒),对标代理 30s
27
+
28
+ # ── AI 生成 ──────────────────────────────────────────────────────────────────
29
+
30
+ RULE_NAME_MAX_LENGTH = 64 # S11: 规则 name 最大长度
31
+ RULE_NAME_PATTERN = r"^[a-z0-9_-]+$" # S11: 规则 name 合法字符
32
+ ATTR_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$" # S29: attr 名合法字符
33
+ CURRENCY_SYMBOLS = frozenset({"¥", "$", "€", "£", "₹", "₩", "₽", "₺", "R$"})
34
+ TRANSFORM_MEMORY_MULTIPLIER = 5 # N104: replace 内存放大倍数上限
35
+
36
+ # ── Worker 与队列 ────────────────────────────────────────────────────────────
37
+ # 使用者: crawler/engine.py _worker() / _pop_domain_aware() / run() 主循环
38
+ WORKER_IDLE_SLEEP = 0.5 # queue 空时 worker 休眠间隔(避免忙等待)
39
+
40
+ RUN_EMPTY_QUEUE_WAIT = 1 # 队列首次空等待秒数(队列瞬时排空不立即退出)
41
+ RUN_EMPTY_QUEUE_CONFIRM = 5 # 队列二次确认等待秒数(排除网络延迟导致的重入队)
42
+
43
+ # ── 关闭与生命周期 ───────────────────────────────────────────────────────────
44
+ # 使用者: crawler/engine.py _run_worker_loop() finally / gui/thread.py CrawlerThread.run()
45
+ SHUTDOWN_PENDING_TIMEOUT = 15 # event loop 清理 pending tasks 超时
46
+ SHUTDOWN_EXECUTOR_TIMEOUT = 10 # default executor shutdown 超时
47
+ SHUTDOWN_ASYNCGEN_TIMEOUT = 10 # async generator shutdown 超时
48
+ IN_FLIGHT_DRAIN_TIMEOUT = 30 # 停止后等待 in_flight 自然排空超时
49
+
50
+ # ── Sitemap 与 robots.txt ────────────────────────────────────────────────────
51
+ # 使用者: network/sitemap.py SitemapDiscovery / network/robots.py RobotsCache
52
+ # 注: 重试次数与退避基数已统一使用 CrawlerConfig.max_retries / retry_backoff_base
53
+ SITEMAP_FETCH_TIMEOUT = 8 # sitemap 单次抓取超时 (秒)
54
+ SITEMAP_MAX_CONTENT_SIZE = 50 * 1024 * 1024 # 50 MiB per sitemap fetch
55
+ SITEMAP_MAX_DECOMPRESSED = 100 * 1024 * 1024 # 100 MiB decompressed
56
+ ROBOTS_FETCH_TIMEOUT = 5 # robots.txt 抓取超时 (秒)
57
+ ROBOTS_FETCH_MAX_CONCURRENT = 8 # robots.txt 并发抓取上限
58
+ ROBOTS_MAX_SIZE = 500 * 1024 # RFC 9309: 500 KiB 上限
59
+
60
+ # ── 浏览器上下文与页面管理 ───────────────────────────────────────────────────
61
+ # 使用者: browser/context_pool.py ContextPool / browser/page_pool.py PagePool
62
+ PAGE_CREATE_RETRIES = 3 # 页面创建最大重试
63
+ PAGE_CREATE_BACKOFF = 1.0 # 页面创建重试退避基数 (秒)
64
+ PAGE_CREATE_TIMEOUT = 15 # context.new_page() asyncio 超时 (秒)
65
+ PAGE_CLOSE_TIMEOUT = 5 # page.close() / context.close() asyncio 超时 (秒)
66
+ ABOUT_BLANK_ASYNCIO_TIMEOUT = 5.0 # remove_broken 中 about:blank goto 超时 (秒)
67
+ SLOT_CREATE_RETRIES = 3 # 槽位创建最大重试
68
+ SLOT_CREATE_BACKOFF = 1.0 # 槽位创建重试退避基数 (秒)
69
+ CONTEXT_CREATE_TIMEOUT = 30 # browser.new_context() asyncio 超时 (秒)
70
+ RELEASE_UNROUTE_TIMEOUT = 3 # 错误恢复时 page.unroute_all() 超时 (秒)
71
+ BROWSER_LAUNCH_TIMEOUT = 60 # Chromium 浏览器启动超时 (秒)
72
+ HARD_CLEANUP_TIMEOUT = 15 # finally 块中资源释放的最硬超时 (秒),不可被 asyncio.shield 绕过
73
+
74
+ # ── 纵深防御超时 ─────────────────────────────────────────────────────────────
75
+ # 使用者: crawler/engine.py _fetch_url() / browser/browser_pool.py / pipeline processors
76
+ # Playwright 原生超时 (page.goto timeout) 通过 cfg.page_timeout 配置。
77
+ # 以下 asyncio.wait_for 超时作为第二层兜底——当 Playwright 因 TCP SYN 挂起、
78
+ # CDP 通道中断等原因不触发自身超时时,asyncio 层强制终止。
79
+ CONTENT_READ_TIMEOUT = 15 # page.content() asyncio 超时 (秒)
80
+ PROCESS_URL_TIMEOUT = 240 # pipeline 单 URL 处理整体超时 (含 prefer_direct 代理回退余量)
81
+ FETCH_PROCESSOR_OVERHEAD = 15 # 非 fetch processor 的时间预算 + 安全余量 (秒)
82
+ DOMAIN_CONCURRENCY_ACQUIRE_TIMEOUT = 60 # 域并发 semaphore 获取超时 (秒)
83
+
84
+ # ── 卡死检测与上下文健康 ────────────────────────────────────────────────────
85
+ # 使用者: crawler/engine.py 主循环 / browser/browser_pool.py _health_check_loop()
86
+ WORKER_STUCK_TIMEOUT = 300 # 所有 worker 进度停滞阈值 (秒),超时强制退出
87
+ STUCK_IN_FLIGHT_TIMEOUT = 300 # queue=0 但 in_flight>0 的悬挂判定阈值 (秒)
88
+ CONTEXT_HEALTH_CHECK_INTERVAL = 30 # 空闲 slot 健康检查间隔 (秒)
89
+ BROWSER_GOTO_BUFFER_S = 5.0 # asyncio.wait_for 超时超过 Playwright 超时的缓冲(秒)
90
+ BROWSER_RESTART_TIMEOUT_MULT = 3 # Browser 重启超时为 HARD_CLEANUP_TIMEOUT 的倍数
91
+
92
+ # ── 代理健康管理 ─────────────────────────────────────────────────────────────
93
+ # 使用者: proxy/_proxy.py ProxyHealthTracker / ProxyManager
94
+ # 断路器模式 (Circuit Breaker): CLOSED → OPEN → HALF_OPEN → CLOSED
95
+ PROXY_FAILURE_THRESHOLD = 3 # 连续失败 → OPEN (完全熔断) 的阈值
96
+ PROXY_COOLDOWN = 30.0 # OPEN → HALF_OPEN 最短冷却时间 (秒)
97
+ PROXY_COOLDOWN_MAX = 120.0 # 冷却时间指数增长上界 (秒)
98
+ PROXY_HALF_OPEN_MIN_DURATION = 15.0 # HALF_OPEN 最短考察窗口 (秒)
99
+ PROXY_HALF_OPEN_MAX_FAILURES = 2 # HALF_OPEN 窗口内最大容忍失败数
100
+ PROXY_DECAY_SECONDS = 120.0 # 故障记录过期时间 (秒),超时后该次失败不再计入 health_score
101
+ PROXY_PROBE_INTERVAL = 5.0 # 后台 TCP 探测间隔 (秒)
102
+ PROXY_PROBE_TIMEOUT = 2.0 # 单次 TCP 连接超时 (秒)
103
+ PROXY_SCORE_WINDOW = 15.0 # health_score 故障密度窗口 (秒)
104
+ PROXY_SCORE_SUCCESS_DECAY = 30.0 # 成功奖励线性衰减 (秒)
105
+ PROXY_HEALTH_BAR_REFRESH = 3.0 # GUI 健康条刷新间隔 (秒)
106
+
107
+ # ── 域管理 ───────────────────────────────────────────────────────────────────
108
+ # 使用者: network/throttling.py / crawler/engine.py run()
109
+
110
+ DOMAIN_CLEANUP_AGE = 3600 # 域限流条目闲置后清理阈值 (秒)
111
+ DNS_CACHE_TTL = 300 # aiohttp DNS 缓存 TTL (秒)
112
+
113
+ # ── 后台维护周期 ─────────────────────────────────────────────────────────────
114
+ # 使用者: crawler/engine.py run() — HealthMonitor 调度
115
+ RETRY_MONITOR_INTERVAL = 10 # 失败重试监视器轮询间隔 (秒)
116
+ RETRY_MONITOR_BATCH = 50 # 每批原子重试回收最大数量
117
+ RATE_LIMITER_CLEANUP_INTERVAL = 600 # 域速率限制器过期条目清理间隔 (秒)
118
+ CONCURRENT_LIMITER_CLEANUP_INTERVAL = 300 # 域并发限制器过期条目清理间隔 (秒)
119
+ RESOURCE_MONITOR_INTERVAL = 60 # 资源监控 (内存/DB/队列) 间隔 (秒)
120
+ HASH_CLEANUP_INTERVAL = 1800 # 内容哈希表过期条目清理间隔 (秒)
121
+ HASH_MAX_AGE = 86400.0 # 内容哈希条目最大保留时间 (秒),24h
122
+
123
+ # ── 连接器 ───────────────────────────────────────────────────────────────────
124
+ # 使用者: crawler/engine.py run() — aiohttp TCPConnector
125
+ CONNECTOR_LIMIT = 100 # aiohttp 总连接数上限
126
+ CONNECTOR_LIMIT_PER_HOST = 10 # aiohttp 单主机连接数上限
127
+
128
+ # ── 诊断 ───────────────────────────────────────────────────────────────────────
129
+ # 使用者: diagnostics.py CrawlDiagnostics
130
+ HTTP_READ_LINE_TIMEOUT = 10.0 # HTTP 健康端点 readline() 超时 (秒)
131
+
132
+ # ── 日志 ─────────────────────────────────────────────────────────────────────
133
+ # 使用者: utils/logging.py / gui/main_window.py / browser/browser_pool.py (MAX_ERROR_MESSAGE_LENGTH)
134
+ FILE_LOG_MAX_BYTES = 10 * 1024 * 1024 # RotatingFileHandler 单文件最大字节数
135
+ FILE_LOG_BACKUP_COUNT = 3 # RotatingFileHandler 历史备份文件数
136
+ MAX_LOG_ITEMS = 500 # GUI 日志列表最大条目数 (超出时清理)
137
+ MAX_ERROR_MESSAGE_LENGTH = 500 # 错误消息截断长度 (防止日志溢出)
138
+
139
+ # ── GUI 控件物理极限 ──────────────────────────────────────────────────────────
140
+ QSPINBOX_MAX = 2147483647 # QSpinBox int32 上界,非业务上限
141
+ QDOUBLESPINBOX_MAX = 1.7976931348623157e308 # QDoubleSpinBox double 上界,非业务上限
142
+ QLINEEDIT_MAX = 32767 # QLineEdit Qt 内部默认 maxLength,非业务上限
143
+
144
+ # ── 下载候选扩展名 (对齐 ADR-0002 下载预设 + Scrapy LinkExtractor) ──────────────
145
+ # 使用者: utils/html.py _is_download_candidate() → extract_links_from_soup()
146
+ # 链接提取时按扩展名识别非 HTML 资源,当前阶段记录为 DOWNLOAD_CANDIDATE drop,
147
+ # 下载模块 (ADR-0002) 落地后路由到下载管线。
148
+ DOWNLOAD_EXTENSIONS = frozenset(
149
+ {
150
+ # archives
151
+ "tar",
152
+ "gz",
153
+ "tgz",
154
+ "bz2",
155
+ "xz",
156
+ "z",
157
+ "7z",
158
+ "zip",
159
+ "rar",
160
+ "jar",
161
+ "iso",
162
+ "dmg",
163
+ "exe",
164
+ "pkg",
165
+ "deb",
166
+ "rpm",
167
+ "apk",
168
+ "msi",
169
+ "msix",
170
+ "sigstore",
171
+ # data
172
+ "csv",
173
+ "tsv",
174
+ "json",
175
+ "xml",
176
+ "yaml",
177
+ "yml",
178
+ "sql",
179
+ "sqlite",
180
+ "db",
181
+ "dat",
182
+ "bin",
183
+ "log",
184
+ # ebooks
185
+ "epub",
186
+ "mobi",
187
+ "azw",
188
+ "azw3",
189
+ # documents
190
+ "pdf",
191
+ "doc",
192
+ "docx",
193
+ "xls",
194
+ "xlsx",
195
+ "ppt",
196
+ "pptx",
197
+ "odt",
198
+ "ods",
199
+ "odp",
200
+ "ps",
201
+ "pages",
202
+ "key",
203
+ "numbers",
204
+ # images (scrapy reference)
205
+ "mng",
206
+ "pct",
207
+ "bmp",
208
+ "gif",
209
+ "jpg",
210
+ "jpeg",
211
+ "png",
212
+ "pst",
213
+ "psp",
214
+ "tif",
215
+ "tiff",
216
+ "ai",
217
+ "drw",
218
+ "dxf",
219
+ "eps",
220
+ "svg",
221
+ "cdr",
222
+ "ico",
223
+ "webp",
224
+ # audio
225
+ "mp3",
226
+ "wma",
227
+ "ogg",
228
+ "wav",
229
+ "ra",
230
+ "aac",
231
+ "mid",
232
+ "au",
233
+ "aiff",
234
+ "flac",
235
+ # video
236
+ "3gp",
237
+ "asf",
238
+ "asx",
239
+ "avi",
240
+ "mov",
241
+ "mp4",
242
+ "mpg",
243
+ "qt",
244
+ "rm",
245
+ "swf",
246
+ "wmv",
247
+ "webm",
248
+ "mkv",
249
+ # web (not HTML)
250
+ "css",
251
+ "js",
252
+ "rss",
253
+ "atom",
254
+ }
255
+ )
256
+
257
+ # ── 资源阻断与 Chromium 启动 ─────────────────────────────────────────────────
258
+ # 使用者: browser/browser_pool.py start() + route / crawler/engine.py run()
259
+ # 资源阻断已收编入下载子系统 (Download)。启用下载后由下载预设的 resource_type
260
+ # 门控 (Layer 1) 统一控制放行/阻断;未启用下载时全部阻断(向后兼容)。
261
+ BLOCKED_RESOURCE_TYPES = frozenset({"image", "font", "media", "websocket", "prefetch", "manifest"})
262
+ CHROMIUM_LAUNCH_ARGS = (
263
+ "--disable-blink-features=AutomationControlled",
264
+ "--disable-dev-shm-usage",
265
+ "--no-sandbox",
266
+ "--disable-gpu",
267
+ "--log-level=3", # 仅 ERROR 级日志,防止代理凭据在 Chromium 调试输出中泄漏
268
+ )
269
+ __all__ = [
270
+ # 解析规则引擎
271
+ "MAX_RULES_TOTAL",
272
+ "MAX_FALLBACK_DEPTH",
273
+ "MAX_FIELDS_PER_RULE",
274
+ "MAX_FIELD_NAME_LENGTH",
275
+ "MULTIPLE_MAX_ITEMS",
276
+ "SELECTOR_TIMEOUT_PER_RULE",
277
+ "MAX_RULE_FILE_SIZE",
278
+ "MAX_RULES_CACHE_SIZE",
279
+ "MAX_JSON_DEPTH",
280
+ "RULES_TMP_MAX_AGE_HOURS",
281
+ "SOURCE_PRIORITY",
282
+ "RULE_NAME_MAX_LENGTH",
283
+ "RULE_NAME_PATTERN",
284
+ "ATTR_NAME_PATTERN",
285
+ "CURRENCY_SYMBOLS",
286
+ "TRANSFORM_MEMORY_MULTIPLIER",
287
+ "MANIFEST_MAX_BYTES",
288
+ "SOURCE_DOWNLOAD_TIMEOUT",
289
+ "SOURCE_DAILY_UPDATE_LIMIT",
290
+ "SOURCE_DEGRADED_COOLDOWN",
291
+ "DOWNLOAD_CONCURRENCY_GLOBAL",
292
+ "DOWNLOAD_CONCURRENCY_PER_SOURCE",
293
+ "MAX_REDIRECTS",
294
+ "MAX_SOURCE_URL_LENGTH",
295
+ # Worker 与队列
296
+ "WORKER_IDLE_SLEEP",
297
+ "RUN_EMPTY_QUEUE_WAIT",
298
+ "RUN_EMPTY_QUEUE_CONFIRM",
299
+ # 关闭与生命周期
300
+ "SHUTDOWN_PENDING_TIMEOUT",
301
+ "SHUTDOWN_EXECUTOR_TIMEOUT",
302
+ "SHUTDOWN_ASYNCGEN_TIMEOUT",
303
+ "IN_FLIGHT_DRAIN_TIMEOUT",
304
+ # Sitemap 与 robots.txt
305
+ "SITEMAP_FETCH_TIMEOUT",
306
+ "SITEMAP_MAX_CONTENT_SIZE",
307
+ "SITEMAP_MAX_DECOMPRESSED",
308
+ "ROBOTS_FETCH_TIMEOUT",
309
+ "ROBOTS_FETCH_MAX_CONCURRENT",
310
+ "ROBOTS_MAX_SIZE",
311
+ # 浏览器上下文与页面管理
312
+ "PAGE_CREATE_RETRIES",
313
+ "PAGE_CREATE_BACKOFF",
314
+ "PAGE_CREATE_TIMEOUT",
315
+ "PAGE_CLOSE_TIMEOUT",
316
+ "ABOUT_BLANK_ASYNCIO_TIMEOUT",
317
+ "SLOT_CREATE_RETRIES",
318
+ "SLOT_CREATE_BACKOFF",
319
+ "CONTEXT_CREATE_TIMEOUT",
320
+ "RELEASE_UNROUTE_TIMEOUT",
321
+ "BROWSER_LAUNCH_TIMEOUT",
322
+ "HARD_CLEANUP_TIMEOUT",
323
+ # 纵深防御超时
324
+ "CONTENT_READ_TIMEOUT",
325
+ "PROCESS_URL_TIMEOUT",
326
+ "FETCH_PROCESSOR_OVERHEAD",
327
+ "DOMAIN_CONCURRENCY_ACQUIRE_TIMEOUT",
328
+ # 卡死检测与上下文健康
329
+ "WORKER_STUCK_TIMEOUT",
330
+ "STUCK_IN_FLIGHT_TIMEOUT",
331
+ "CONTEXT_HEALTH_CHECK_INTERVAL",
332
+ "BROWSER_GOTO_BUFFER_S",
333
+ "BROWSER_RESTART_TIMEOUT_MULT",
334
+ # 代理健康管理
335
+ "PROXY_FAILURE_THRESHOLD",
336
+ "PROXY_COOLDOWN",
337
+ "PROXY_COOLDOWN_MAX",
338
+ "PROXY_HALF_OPEN_MIN_DURATION",
339
+ "PROXY_HALF_OPEN_MAX_FAILURES",
340
+ "PROXY_DECAY_SECONDS",
341
+ "PROXY_PROBE_INTERVAL",
342
+ "PROXY_PROBE_TIMEOUT",
343
+ "PROXY_SCORE_WINDOW",
344
+ "PROXY_SCORE_SUCCESS_DECAY",
345
+ "PROXY_HEALTH_BAR_REFRESH",
346
+ # 域管理
347
+ "DOMAIN_CLEANUP_AGE",
348
+ "DNS_CACHE_TTL",
349
+ # 后台维护周期
350
+ "RETRY_MONITOR_INTERVAL",
351
+ "RETRY_MONITOR_BATCH",
352
+ "RATE_LIMITER_CLEANUP_INTERVAL",
353
+ "CONCURRENT_LIMITER_CLEANUP_INTERVAL",
354
+ "RESOURCE_MONITOR_INTERVAL",
355
+ "HASH_CLEANUP_INTERVAL",
356
+ "HASH_MAX_AGE",
357
+ # 诊断
358
+ "HTTP_READ_LINE_TIMEOUT",
359
+ # 连接器
360
+ "CONNECTOR_LIMIT",
361
+ "CONNECTOR_LIMIT_PER_HOST",
362
+ # 日志
363
+ "FILE_LOG_MAX_BYTES",
364
+ "FILE_LOG_BACKUP_COUNT",
365
+ "MAX_LOG_ITEMS",
366
+ "MAX_ERROR_MESSAGE_LENGTH",
367
+ # GUI 控件物理极限
368
+ "QSPINBOX_MAX",
369
+ "QDOUBLESPINBOX_MAX",
370
+ "QLINEEDIT_MAX",
371
+ # 下载候选扩展名
372
+ "DOWNLOAD_EXTENSIONS",
373
+ # 资源阻断与 Chromium 启动
374
+ "BLOCKED_RESOURCE_TYPES",
375
+ "CHROMIUM_LAUNCH_ARGS",
376
+ ]
@@ -0,0 +1,26 @@
1
+ """JSON 序列化兼容层 — 优先 orjson 加速,回退标准库 json。
2
+
3
+ 策略: _json_dumps 始终返回 bytes 并以 \n 结尾(JSONL 格式)。
4
+ 调用方可直接写入 io.BytesIO,无需 str→bytes 编解码。
5
+ 机制: orjson 为可选加速后端([fast] extra),未安装时透明回退 stdlib json。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger("astrocrawl.json_compat")
15
+
16
+ try:
17
+ import orjson as _json_mod
18
+
19
+ def _json_dumps(obj: Any) -> bytes:
20
+ return _json_mod.dumps(obj, option=_json_mod.OPT_APPEND_NEWLINE)
21
+
22
+ except ImportError:
23
+ logger.debug("event=orjson_unavailable dumper=stdlib")
24
+
25
+ def _json_dumps(obj: Any) -> bytes:
26
+ return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
@@ -0,0 +1,54 @@
1
+ """打包环境检测与路径适配。
2
+
3
+ 打包工具(如 Nuitka)将 Python 应用编译为独立可执行文件。
4
+ 此模块在应用入口最早被调用,负责检测运行环境并设置
5
+ PLAYWRIGHT_BROWSERS_PATH,使 Playwright 能找到捆绑的浏览器。
6
+
7
+ 零外部依赖,可安全在 main() 中作为首条语句导入。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def is_packaged() -> bool:
18
+ """任意打包环境。"""
19
+ return getattr(sys, "frozen", False)
20
+
21
+
22
+ def get_bundle_dir() -> Path:
23
+ """返回打包应用的根目录。
24
+
25
+ --standalone 模式:可执行文件所在目录。
26
+ --onefile 模式:临时解压目录(sys._MEIPASS)。
27
+ 开发模式:项目根目录(向上两级)。
28
+ """
29
+ if not is_packaged():
30
+ return Path(__file__).resolve().parent.parent
31
+
32
+ meipass = getattr(sys, "_MEIPASS", None)
33
+ if meipass:
34
+ return Path(meipass)
35
+
36
+ return Path(sys.executable).resolve().parent
37
+
38
+
39
+ def setup() -> None:
40
+ """设置打包环境:配置路径与环境变量。
41
+
42
+ 必须在任何 Playwright 相关代码之前调用。
43
+ 非打包环境下为空操作。
44
+ """
45
+ if not is_packaged():
46
+ return
47
+
48
+ bundle = get_bundle_dir()
49
+
50
+ # Playwright 浏览器路径
51
+ # 构建时通过 --include-data-dir 将 ~/.cache/ms-playwright/ 映射到此目录
52
+ browsers_path = bundle / "playwright_browsers"
53
+ if browsers_path.is_dir():
54
+ os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_path)
@@ -0,0 +1,148 @@
1
+ """路径路由策略 — 主/回退双路径决策(ADR-0010 决策 11a)。
2
+
3
+ 纯策略对象,零内部依赖(仅从 _types 导入 FetchErrorCategory 枚举)。
4
+ 消费者按需取用:proxy/ 底座 + _path_strategy.py 自行组装回退逻辑。
5
+
6
+ 对标: _retry_strategy.py 模式 — kernel 层独立文件,纯策略,消费者按需引入。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from astrocrawl._types import FetchErrorCategory
14
+
15
+
16
+ class PathSwitch:
17
+ """封装主路径与回退路径的通用策略。
18
+
19
+ Attributes:
20
+ main: 主抓取路径 ("proxy" | "direct")
21
+ fallback: 主路径耗尽时的次路径 (None | "proxy" | "direct")
22
+ trigger: 触发回退的条件 (None | "all_proxies_dead" | "connectivity_error")
23
+ scope: 回退生效的层级 ("slot" | "url")
24
+ on_exhausted: 次路径也耗尽时的行为 ("pause" | "fail")
25
+ """
26
+
27
+ # 四种 proxy_mode 的预置配置。for_mode() 据此查找模式 → 构造 PathSwitch。
28
+ # 增删模式只需修改此 dict,_VALID_MODES 和 for_mode() 自动同步。
29
+ _MODE_CONFIGS = {
30
+ "direct_only": {"main": "direct"},
31
+ "proxy_only": {"main": "proxy", "on_exhausted": "pause"},
32
+ "prefer_direct": {"main": "direct", "fallback": "proxy", "trigger": "connectivity_error", "scope": "url"},
33
+ "prefer_proxy": {"main": "proxy", "fallback": "direct", "trigger": "all_proxies_dead", "scope": "url"},
34
+ }
35
+ _VALID_MODES = frozenset(_MODE_CONFIGS.keys())
36
+
37
+ def __init__(
38
+ self,
39
+ main: str,
40
+ fallback: Optional[str] = None,
41
+ trigger: Optional[str] = None,
42
+ scope: str = "url",
43
+ on_exhausted: str = "fail",
44
+ ) -> None:
45
+ if main not in ("proxy", "direct"):
46
+ raise ValueError(f"main must be 'proxy' or 'direct', got {main!r}")
47
+ if fallback not in (None, "proxy", "direct"):
48
+ raise ValueError(f"fallback must be 'proxy', 'direct', or None, got {fallback!r}")
49
+ if trigger not in (None, "all_proxies_dead", "connectivity_error"):
50
+ raise ValueError(f"invalid trigger: {trigger!r}")
51
+ if scope not in ("slot", "url"):
52
+ raise ValueError(f"scope must be 'slot' or 'url', got {scope!r}")
53
+ if on_exhausted not in ("pause", "fail"):
54
+ raise ValueError(f"on_exhausted must be 'pause' or 'fail', got {on_exhausted!r}")
55
+
56
+ self.main = main
57
+ self.fallback = fallback
58
+ self.trigger = trigger
59
+ self.scope = scope
60
+ self.on_exhausted = on_exhausted
61
+
62
+ @classmethod
63
+ def for_mode(cls, mode: str) -> "PathSwitch":
64
+ """从 proxy_mode 配置字符串创建。
65
+
66
+ direct_only: 单路径直连
67
+ proxy_only: 单路径代理,全死暂停
68
+ prefer_direct: 直连优先,连通性错误时升级代理
69
+ prefer_proxy: 代理优先,全死时降级直连
70
+ """
71
+ config = cls._MODE_CONFIGS.get(mode)
72
+ if config is None:
73
+ raise ValueError(f"Unknown proxy_mode: {mode!r}, valid: {sorted(cls._VALID_MODES)}")
74
+ return cls(**config)
75
+
76
+ # ── 属性查询 ────────────────────────────────────────
77
+
78
+ @property
79
+ def main_is_proxy(self) -> bool:
80
+ """新槽位默认是否使用代理。"""
81
+ return self.main == "proxy"
82
+
83
+ @property
84
+ def has_fallback(self) -> bool:
85
+ """是否存在次路径。"""
86
+ return self.fallback is not None
87
+
88
+ @property
89
+ def fallback_is_proxy(self) -> bool:
90
+ """次路径是否为代理 (prefer_direct 升级)。"""
91
+ return self.fallback == "proxy"
92
+
93
+ @property
94
+ def fallback_is_direct(self) -> bool:
95
+ """次路径是否为直连 (prefer_proxy 降级)。"""
96
+ return self.fallback == "direct"
97
+
98
+ # ── 决策方法 ────────────────────────────────────────
99
+
100
+ def should_fallback_for_error(self, category: FetchErrorCategory) -> bool:
101
+ """URL 级决策:给定的抓取错误是否应触发回退。
102
+
103
+ 仅 prefer_direct 需要:直连连通性错误 → 升级代理。
104
+ """
105
+ if not self.has_fallback or self.trigger != "connectivity_error":
106
+ return False
107
+ return category in _CONNECTIVITY_ERRORS
108
+
109
+ def should_fallback_for_proxy_exhaustion(self, category: Optional[FetchErrorCategory] = None) -> bool:
110
+ """prefer_proxy: 代理池耗尽时触发直连回退。
111
+
112
+ Args:
113
+ category: 错误分类 (可选)。传入时排除确定非代理的错误类别
114
+ (DNS/SSL/HTTP_4XX/redirects — 这些与代理状态无关)。
115
+ """
116
+ if not self.has_fallback or self.trigger != "all_proxies_dead":
117
+ return False
118
+ if category is not None and category in _NON_PROXY_ERRORS:
119
+ return False
120
+ return True
121
+
122
+
123
+ _CONNECTIVITY_ERRORS = frozenset(
124
+ {
125
+ FetchErrorCategory.CONNECTION_REFUSED,
126
+ FetchErrorCategory.TIMEOUT,
127
+ FetchErrorCategory.CONNECTION_RESET,
128
+ FetchErrorCategory.GENERIC,
129
+ }
130
+ )
131
+
132
+ # 确定非代理错误——prefer_proxy 在这些错误上回退到直连无意义
133
+ _NON_PROXY_ERRORS = frozenset(
134
+ {
135
+ FetchErrorCategory.DNS,
136
+ FetchErrorCategory.SSL,
137
+ FetchErrorCategory.HTTP_4XX,
138
+ FetchErrorCategory.DOWNLOAD,
139
+ FetchErrorCategory.TOO_MANY_REDIRECTS,
140
+ }
141
+ )
142
+
143
+
144
+ __all__ = [
145
+ "PathSwitch",
146
+ "_CONNECTIVITY_ERRORS",
147
+ "_NON_PROXY_ERRORS",
148
+ ]