-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmonitor.py
More file actions
2087 lines (1841 loc) · 90.6 KB
/
Copy pathmonitor.py
File metadata and controls
2087 lines (1841 loc) · 90.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
monitor.py — 监控主程序
========================
程序入口,协调抓取、存储、通知、自动预订的完整流程。
运行方式
--------
python monitor.py 持续监控(默认,带智能轮询和 SIGHUP 热重载)
python monitor.py --once 单次运行后退出(适合 cron 任务)
python monitor.py --test 抓取并打印 JSON,不写库不发通知(用于验证抓取)
核心流程(每轮)
----------------
1. `dispatch_scrape_tasks()`(sync,在 executor 线程中运行)抓取所有 source 房源 + 完整扫描信号
2. 记录完整扫描信号(Phase 2 仅观察,不参与状态收敛)
3. `storage.diff()` 对比库中快照,产出 new_listings / status_changes
4. 遍历启用的用户,构建自动预订候选;立即将 try_book() 提交到线程池
5. 发送新房源/状态变更通知(与步骤 4 中的预订并发进行)
6. 等待预订完成,推送预订结果通知
7. 写 meta(last_scrape_at);按时间间隔发心跳
智能轮询
--------
get_interval() 根据荷兰本地时间判断是否处于高峰期(默认 8:30-10:00 工作日)。
高峰期使用 PEAK_INTERVAL(默认 60s),其余时间使用 CHECK_INTERVAL(默认 300s)。
实际等待时间在基准值 ±20% 随机抖动,避免多实例同步。
热重载
------
收到 SIGHUP 信号后,在本轮结束时重载 .env + SQLite 用户配置,无需重启进程。
Web 面板的「立即应用」按钮通过发送 SIGHUP(`kill -HUP <PID>`)触发。
依赖模块
--------
scraper → storage → notifier → booker(单向,无循环)
config / users:被各模块按需 import
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import signal
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from dotenv import load_dotenv
from booker import BookingBlockedError, PrewarmedSession
from config import DATA_DIR, ENV_PATH, get_proxy_url, is_proxy_native_fallback_active, load_config
from models import STATUS_AVAILABLE
from notifier import BaseNotifier, WebNotifier, create_user_notifier
from mcore.booking import RetryQueue, area_key, book_with_fallback
from mcore.interval import apply_jitter, get_interval
from mcore.prewarm import PrewarmCache
from scrapers import (
BlockedError,
ProxyError,
RateLimitError,
ScrapeNetworkError,
UpstreamMaintenanceError,
dispatch_scrape_tasks,
is_proxy_service_error,
)
from update_checker import check_for_updates
from storage import Storage
from users import UserConfig, load_users, save_users
from models import Listing
async def _dispatch_scrape_tasks_async(
loop: asyncio.AbstractEventLoop,
selected: list,
*,
isolated: bool = False,
):
"""
Run the synchronous scraper dispatcher from async monitor code.
CloakBrowser wraps Playwright's Sync API. On Linux containers, launching it
from the long-lived default executor can inherit enough asyncio state for
Playwright to reject startup with "Sync API inside the asyncio loop". H2S
therefore uses a short-lived dedicated thread, while non-browser sources
keep using the default executor.
"""
if isolated:
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="h2s-scrape") as executor:
return await loop.run_in_executor(
executor,
lambda: dispatch_scrape_tasks(selected),
)
return await loop.run_in_executor(
None,
lambda: dispatch_scrape_tasks(selected),
)
def _setup_logging(level: str) -> None:
"""
配置主日志(monitor.log,全量 INFO+)+ 错误日志(errors.log,仅 WARNING+)。
错误日志的存在意义
------------------
monitor.log 长跑下 INFO 噪音淹没真正的告警;errors.log 单独保留
WARNING/ERROR/CRITICAL,便于事后排查抓取失败、下单异常、限流等问题。
- 更大的 backupCount:错误稀疏,保留更长时间窗口(10MB 历史)
- 更详细的 formatter:含 funcName:lineno,一眼定位问题源
- 全局 root logger 接管:所有模块的 logger.warning/error 自动入此文件
"""
fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
logging.basicConfig(level=getattr(logging, level, "INFO"), format=fmt)
logging.getLogger("asyncio").setLevel(logging.WARNING)
from logging.handlers import RotatingFileHandler
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 主日志(INFO+):与之前一致,Web 面板默认查看
main_fh = RotatingFileHandler(
str(DATA_DIR / "monitor.log"),
maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8",
)
main_fh.setFormatter(logging.Formatter(fmt))
main_fh.setLevel(getattr(logging, level, "INFO"))
logging.getLogger().addHandler(main_fh)
# 错误日志(WARNING+):抓取/下单异常的专用归档
error_fh = RotatingFileHandler(
str(DATA_DIR / "errors.log"),
maxBytes=2 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
error_fh.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s.%(funcName)s:%(lineno)d %(message)s"
))
error_fh.setLevel(logging.WARNING)
logging.getLogger().addHandler(error_fh)
logger = logging.getLogger("monitor")
def _unpack_scrape_result(result):
"""兼容测试/旧 monkeypatch 返回 list;真实 scraper 返回 (listings, completeness)。"""
if (
isinstance(result, tuple)
and len(result) == 2
and isinstance(result[1], dict)
):
return result
return result, {}
def _log_scrape_completeness(completeness: dict[str, bool]) -> None:
"""Phase 2: 只记录城市完整扫描信号,不参与任何收敛行为。"""
if not completeness:
return
complete_n = sum(1 for ok in completeness.values() if ok)
logger.info(
"本轮完整扫描: %d/%d 城市 (%s)",
complete_n,
len(completeness),
", ".join(f"{city}={'✓' if ok else '✗'}" for city, ok in completeness.items()),
)
def _mark_stale_listings_for_complete_cities(
storage: Storage,
completeness: dict[str, bool],
*,
days: int = 7,
lottery_days: int = 2,
) -> int:
"""Phase 3: 只对本轮完整扫描成功的城市执行 stale listing 收敛。"""
complete_cities: list[str] = []
complete_source_cities: list[tuple[str, str]] = []
for key, ok in completeness.items():
if not ok:
continue
if ":" in key:
source, city = key.split(":", 1)
complete_source_cities.append((source, city))
else:
complete_cities.append(key)
if not complete_cities and not complete_source_cities:
logger.info("跳过 stale listing 状态收敛:本轮无完整扫描城市")
return 0
labels = sorted(complete_cities + [f"{s}:{c}" for s, c in complete_source_cities])
logger.info(
"执行 stale listing 状态收敛:完整城市 %s",
", ".join(labels),
)
return storage.mark_stale_listings(
days=days,
lottery_days=lottery_days,
cities=complete_cities if complete_cities else None,
source_city_pairs=complete_source_cities if complete_source_cities else None,
)
def _task_labels(tasks) -> list[str]:
return [f"{t.source}:{t.city_display}" for t in tasks]
def _listing_booking_key(listing: Listing) -> tuple[str, str]:
"""自动预订去重键:同 source + id 的房源每轮只允许一个用户尝试。"""
source = (getattr(listing, "source", "") or "holland2stay").strip().lower()
return source, str(listing.id)
def _assign_auto_book_candidates(
raw_candidates: dict[str, list[Listing]],
user_notifiers: UserNotifiers,
) -> dict[str, list[Listing]]:
"""
把自动预订候选从“每用户匹配”收敛成“每房源唯一归属”。
多用户模式下,同一套房源可能同时满足多个用户的自动预订条件。直接让所有
用户并发预订同一套房源会制造无意义的竞态,也更容易触发平台风控。这里在
提交 executor 前做一次进程内分配:同一 listing 每轮只交给一个用户;多套
listing 同时出现时按当前已分配数量做简单均衡,平局按用户配置顺序决定。
"""
assigned: dict[str, list[Listing]] = {u.id: [] for u, _ in user_notifiers}
user_order = {u.id: idx for idx, (u, _) in enumerate(user_notifiers)}
user_names = {u.id: u.name for u, _ in user_notifiers}
assigned_count = {u.id: 0 for u, _ in user_notifiers}
by_listing: dict[tuple[str, str], tuple[Listing, list[str]]] = {}
for user, _ in user_notifiers:
seen_for_user: set[tuple[str, str]] = set()
for listing in raw_candidates.get(user.id, []):
key = _listing_booking_key(listing)
if key in seen_for_user:
continue
seen_for_user.add(key)
if key not in by_listing:
by_listing[key] = (listing, [])
by_listing[key][1].append(user.id)
for listing, user_ids in by_listing.values():
eligible = [uid for uid in user_ids if uid in assigned]
if not eligible:
continue
chosen = min(eligible, key=lambda uid: (assigned_count[uid], user_order[uid]))
assigned[chosen].append(listing)
assigned_count[chosen] += 1
if len(eligible) > 1:
skipped = [user_names[uid] for uid in eligible if uid != chosen]
logger.info(
"[%s] 自动预订候选去重: %s 同时匹配 %d 个用户,已分配给当前用户,跳过: %s",
user_names[chosen],
listing.name,
len(eligible),
", ".join(skipped),
)
return assigned
# 自适应轮询参数(固定,不需要用户配置)
# 每轮成功后将当前间隔乘以此系数(5% 缩短),缓慢逼近 min_interval
_ADAPTIVE_DECREASE = 0.95
# 遭遇 429 后将当前间隔乘以此系数(翻倍),快速退避
_ADAPTIVE_INCREASE = 2.0
# Cloudflare 403 屏蔽冷却时间(秒)。比 429 的 5 min 更长 —— 等待无法自动恢复,
# 给用户/运维时间换代理或重启进程。
_BLOCKED_COOLDOWN = 900 # 15 分钟
_BLOCKED_COOLDOWN_MAX = 7200 # 连续 Cloudflare 403 时最长冷却 2 小时
# 平台维护冷却时间(秒)。H2S 公告通常 1–2 小时窗口,15 分钟一次再探即可:
# 探到还在维护 → 继续冷却;探到恢复 → 当轮成功,正常回到 check_interval。
# 不发用户告警,不计入 network_fail_streak,安静等。
_MAINTENANCE_COOLDOWN = 900 # 15 分钟
# 连续网络失败阈值:连续 N 次全部城市第 1 页网络失败时触发冷却,
# 避免坏代理/断网时监控空转刷屏 error log
_NETWORK_FAIL_THRESHOLD = 3
_NETWORK_FAIL_COOLDOWN = 300 # 5 分钟
_PROXY_CONFIRM_RETRY_DELAY = 60 # 单次代理故障先短冷却复核,不立刻切换/直连
_NATIVE_PROXY_FALLBACK_INTERVAL = 600 # 10 分钟;代理全挂时直连原生 IP 的最高频率
_H2S_SOURCE = "holland2stay"
_PREWARM_CANDIDATE_WAIT_SEC = 2.0 # 有真实候选时最多等 2s 让预登录赶上快速通道
_H2S_LOGIN_BLOCKED_SUPPRESS_SEC = 3600 # H2S 登录/预订 403 后 1 小时内不碰登录链路
_H2S_CIRCUIT_BASE_COOLDOWN = 1800 # H2S 抓取 403 后 30 分钟后做 canary
_H2S_CIRCUIT_MAX_COOLDOWN = 21600 # H2S 抓取连续 403 时最多暂停 6 小时
_H2S_LONG_BLOCK_STREAK = 3 # 第 3 次连续 H2S 403 起视为长时间被 block
_H2S_LONG_BLOCK_NOTIFY_INTERVAL = 21600 # 长时间 block admin 告警 6 小时最多一次
# 屏蔽通知节流:避免每轮抓取都给用户推一次相同的告警。
# 状态是模块级,进程重启后清零(重启后第一轮屏蔽会再发通知,符合预期)。
_last_block_notify_at: float = 0.0
_BLOCK_NOTIFY_INTERVAL = 1800 # 30 分钟
_last_h2s_long_block_notify_at: float = 0.0
# 维护通知节流:admin 已经在 dashboard banner 上看到维护态了,再叠加 web 通知
# 主要是让 admin 在收 push(如果接了)/ 刷通知面板时也能看到一条记录。
# 间隔比屏蔽长一截——维护态用户什么都做不了,没必要 30 min 一刷。
_last_maintenance_notify_at: float = 0.0
_MAINTENANCE_NOTIFY_INTERVAL = 3600 # 1 小时
def _should_notify_block() -> bool:
"""是否该发屏蔽通知。30 分钟最多一次,避免持续屏蔽时刷屏。"""
global _last_block_notify_at
now = time.monotonic()
if _last_block_notify_at <= 0 or now - _last_block_notify_at >= _BLOCK_NOTIFY_INTERVAL:
_last_block_notify_at = now
return True
return False
def _should_notify_h2s_long_block() -> bool:
"""H2S 长时间 403 后是否该通知 admin。6 小时最多一次。"""
global _last_h2s_long_block_notify_at
now = time.monotonic()
if (
_last_h2s_long_block_notify_at <= 0
or now - _last_h2s_long_block_notify_at >= _H2S_LONG_BLOCK_NOTIFY_INTERVAL
):
_last_h2s_long_block_notify_at = now
return True
return False
def _should_notify_maintenance() -> bool:
"""是否该给 admin 发维护通知。1 小时最多一次。"""
global _last_maintenance_notify_at
now = time.monotonic()
if (
_last_maintenance_notify_at <= 0
or now - _last_maintenance_notify_at >= _MAINTENANCE_NOTIFY_INTERVAL
):
_last_maintenance_notify_at = now
return True
return False
# 代理失效通知节流:代理挂了 admin 也只需要知道一次,30 min 一条够。
_last_proxy_notify_at: float = 0.0
_PROXY_NOTIFY_INTERVAL = 1800 # 30 分钟
def _should_notify_proxy() -> bool:
"""是否该给 admin 发代理失效通知。30 分钟最多一次。"""
global _last_proxy_notify_at
now = time.monotonic()
if _last_proxy_notify_at <= 0 or now - _last_proxy_notify_at >= _PROXY_NOTIFY_INTERVAL:
_last_proxy_notify_at = now
return True
return False
# 未分类/管线错误通知节流:某个反复抛错的内部异常(如 DB 锁死、磁盘满)若每轮
# 都通知会刷屏 admin。和代理/屏蔽一样 30 min 一条。
_last_internal_notify_at: float = 0.0
_INTERNAL_NOTIFY_INTERVAL = 1800 # 30 分钟
def _should_notify_internal() -> bool:
"""是否该给 admin 发未分类/管线内部错误通知。30 分钟最多一次。"""
global _last_internal_notify_at
now = time.monotonic()
if (
_last_internal_notify_at <= 0
or now - _last_internal_notify_at >= _INTERNAL_NOTIFY_INTERVAL
):
_last_internal_notify_at = now
return True
return False
def _h2s_login_suppressed_remaining() -> int:
"""H2S 登录/预订链路是否因 403 被临时抑制;返回剩余秒数。"""
return max(0, int(_h2s_login_blocked_until - time.monotonic()))
def _mark_h2s_login_blocked(reason: BaseException | str) -> None:
"""H2S 登录/预订遇到 Cloudflare 403 后,短期内停止触碰登录链路。"""
global _h2s_login_blocked_until
_h2s_login_blocked_until = max(
_h2s_login_blocked_until,
time.monotonic() + _H2S_LOGIN_BLOCKED_SUPPRESS_SEC,
)
prewarm_cache.clear()
logger.warning(
"🚫 H2S 登录/预订遇到 Cloudflare WAF,未来 %d 秒内暂停登录链路: %s",
_H2S_LOGIN_BLOCKED_SUPPRESS_SEC,
reason,
)
def _h2s_circuit_remaining() -> int:
"""H2S 抓取 circuit breaker 剩余暂停秒数。"""
return max(0, int(_h2s_circuit_open_until - time.monotonic()))
def _mark_h2s_scrape_blocked(reason: BaseException | str) -> int:
"""H2S 抓取被 Cloudflare 403 后,打开 source-level circuit breaker。"""
global _h2s_circuit_fail_streak, _h2s_circuit_open_until, _h2s_circuit_reason
_h2s_circuit_fail_streak += 1
cooldown = min(
_H2S_CIRCUIT_MAX_COOLDOWN,
_H2S_CIRCUIT_BASE_COOLDOWN * (2 ** max(0, _h2s_circuit_fail_streak - 1)),
)
_h2s_circuit_open_until = time.monotonic() + cooldown
_h2s_circuit_reason = str(reason)
_mark_h2s_login_blocked(reason)
logger.error(
"🚫 H2S source 熔断:连续抓取 403=%d,暂停 H2S %d 秒;其他 source 继续运行。原因: %s",
_h2s_circuit_fail_streak,
cooldown,
reason,
)
return cooldown
def _mark_h2s_scrape_recovered() -> None:
"""H2S canary 成功后关闭 circuit breaker,下一轮恢复完整 H2S 抓取。"""
global _h2s_circuit_fail_streak, _h2s_circuit_open_until, _h2s_circuit_reason
if _h2s_circuit_fail_streak or _h2s_circuit_open_until:
logger.info(
"✅ H2S canary 抓取成功,关闭 source 熔断(之前连续 403=%d)",
_h2s_circuit_fail_streak,
)
_h2s_circuit_fail_streak = 0
_h2s_circuit_open_until = 0.0
_h2s_circuit_reason = ""
def _split_h2s_tasks(tasks) -> tuple[list, list]:
"""拆分 H2S 与其它 source 任务,便于只熔断 H2S。"""
h2s_tasks = [t for t in tasks if t.source == _H2S_SOURCE]
other_tasks = [t for t in tasks if t.source != _H2S_SOURCE]
return other_tasks, h2s_tasks
def _select_h2s_tasks_for_circuit(h2s_tasks: list) -> tuple[list, str]:
"""
根据 H2S circuit 状态选择本轮 H2S 任务。
Returns
-------
(selected_tasks, mode)
normal : circuit 未打开,抓全部 H2S 城市
open : circuit 冷却中,不抓 H2S
canary : 冷却到期,只抓 1 个 H2S 城市探测恢复
"""
if not h2s_tasks:
return [], "none"
remaining = _h2s_circuit_remaining()
if remaining > 0:
logger.warning(
"🚫 H2S source 熔断中,跳过 %d 个 H2S 任务,%d 秒后 canary。最近原因: %s",
len(h2s_tasks),
remaining,
_h2s_circuit_reason or "unknown",
)
return [], "open"
if _h2s_circuit_fail_streak > 0 or _h2s_circuit_open_until > 0:
logger.warning(
"🚫 H2S source 熔断到期,本轮只用 1 个城市做 canary: %s",
h2s_tasks[0].city_display,
)
return h2s_tasks[:1], "canary"
return h2s_tasks, "normal"
_PID_FILE = DATA_DIR / "monitor.pid"
_RELOAD_REQUEST_FILE = DATA_DIR / "monitor.reload"
# 热重载事件(SIGHUP → 唤醒 main_loop 中的 sleep,立即重载配置)
_reload_event: asyncio.Event | None = None
# 类型别名:每个用户与其对应通知器的配对列表
UserNotifiers = list[tuple[UserConfig, BaseNotifier]]
# mcore 服务实例(进程生命周期内单例)
retry_queue = RetryQueue()
prewarm_cache = PrewarmCache()
_h2s_login_blocked_until: float = 0.0
_h2s_circuit_open_until: float = 0.0
_h2s_circuit_fail_streak: int = 0
_h2s_circuit_reason: str = ""
# ------------------------------------------------------------------ #
# PID & 信号管理
# ------------------------------------------------------------------ #
def _write_pid() -> None:
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
_PID_FILE.write_text(str(os.getpid()))
logger.debug("PID %d 已写入 %s", os.getpid(), _PID_FILE)
def _remove_pid() -> None:
_PID_FILE.unlink(missing_ok=True)
def _consume_reload_request_file() -> bool:
"""
消费一次文件触发的热重载请求。
Returns
-------
True : 检测到请求并已删除请求文件
False : 当前没有待处理请求
说明
----
这是 Windows 上 Web 面板「立即生效」的主要通信方式。
在 Unix 上也作为 SIGHUP 失败时的回退方案。
"""
if not _RELOAD_REQUEST_FILE.exists():
return False
try:
_RELOAD_REQUEST_FILE.unlink()
except FileNotFoundError:
return False
return True
def _setup_signals(loop: asyncio.AbstractEventLoop) -> None:
"""注册 SIGHUP 处理器:收到信号后唤醒热重载事件。"""
def _handler(_signum: int, _frame: object) -> None:
if _reload_event is not None:
loop.call_soon_threadsafe(_reload_event.set)
logger.info("收到 SIGHUP,将在本轮结束后热重载配置")
try:
signal.signal(signal.SIGHUP, _handler)
except (OSError, AttributeError):
logger.debug("SIGHUP 不可用(非 Unix 系统),跳过信号注册")
# ------------------------------------------------------------------ #
# 核心逻辑
# ------------------------------------------------------------------ #
#
# run_once() 是一轮「抓取 → 对比 → 通知 → 自动预订」的编排器。各阶段拆成
# 下面这组 _round_* 辅助函数,run_once 只负责串联与错误分支控制流。
# 拆分原则:纯/有界副作用的段落抽出去;raise / return 等控制流留在 run_once。
async def _broadcast_error(
user_notifiers: "UserNotifiers",
web_notifier: "WebNotifier | None",
msg: str,
) -> None:
"""把一条错误文案广播给所有用户渠道 + web 面板。
抓取异常分支里 "for notifier: send_error / if web_notifier: send_error"
这段重复了三次,抽出来消除重复。"""
for _, notifier in user_notifiers:
await notifier.send_error(msg)
if web_notifier:
await web_notifier.send_error(msg)
async def _notify_admin_only(
storage: "Storage",
web_notifier: "WebNotifier | None",
msg: str,
*,
kind: str,
) -> None:
"""把一条错误只发给 admin(web 面板 + admin push),不打扰普通用户。
用于普通用户无法采取行动的内部/系统级错误(代理失效、数据库/通知管线
故障、未分类异常)。两个渠道各自吞异常——告警通道本身不该再把 run_once
带崩。"""
if web_notifier:
try:
await web_notifier.send_error(msg)
except Exception:
logger.debug("admin web 告警发送失败(已忽略)", exc_info=True)
from mcore import push as _push
try:
await _push.dispatch_admin(storage, msg, kind=kind)
except Exception:
logger.debug("admin push 告警发送失败(已忽略)", exc_info=True)
def _print_dry_run(fresh: list["Listing"], user_notifiers: "UserNotifiers") -> None:
"""--test 模式打印抓取结果,不写库不发通知。
flush=True:管道/重定向环境确保即时输出。非 ASCII 字符(房源名含荷兰语
特殊字母)在某些终端编码下可能抛 UnicodeEncodeError,跳过不崩。"""
def _safe_print(*args, **kw):
try:
print(*args, **kw)
except UnicodeEncodeError:
print(*(str(a).encode("ascii", "replace").decode() for a in args), **kw)
_safe_print(f"\n{'=' * 60}", flush=True)
_safe_print(f"[DRY RUN] 抓取结果(共 {len(fresh)} 条)", flush=True)
for user, _ in user_notifiers:
if not user.listing_filter.is_empty():
matched = [l for l in fresh if user.listing_filter.passes(l)]
_safe_print(f" 用户 [{user.name}] 过滤后符合:{len(matched)} 条", flush=True)
_safe_print('=' * 60, flush=True)
for l in fresh:
_safe_print(f" [{l.status:22s}] {l.price_display:7s} | {l.available_from or '?':12s} | {l.name}", flush=True)
_safe_print('=' * 60, flush=True)
def _clear_maintenance_meta_if_recovered(storage: Storage) -> None:
"""抓取成功后,若之前处于维护态则清掉 maintenance meta,让 dashboard banner 消失。
写 ended_at 留个最近一次恢复时间戳便于排查;seen_at 清空即"不在维护中"。"""
if storage.get_meta("upstream_maintenance_seen_at", default=""):
storage.set_meta(
"upstream_maintenance_ended_at",
datetime.now(timezone.utc).isoformat(),
)
storage.set_meta("upstream_maintenance_seen_at", "")
storage.set_meta("upstream_maintenance_last_at", "")
logger.info("🔧→✅ H2S 平台维护已结束,抓取恢复正常")
def _collect_booking_candidates(
new_listings: list["Listing"],
status_changes: list[tuple["Listing", str, str]],
fresh: list["Listing"],
user_notifiers: "UserNotifiers",
) -> tuple[dict[str, list["Listing"]], dict[str, tuple[str, str]]]:
"""纯内存收集每个用户的自动预订候选(不发任何通知 / 不触网)。
三个来源合并:
1. new_listings 中新上线即 Available to book 的
2. status_changes 中变为 Available to book 的
3. 重试队列里上轮 race_lost、本轮仍 Available to book 的(diff() 不产事件,手动补)
Returns
-------
(ab_candidates, status_transition)
ab_candidates[user_id] : 该用户的候选 Listing 列表(已去重 + 经分配)
status_transition[lid] : listing.id → (old, new),供日志区分触发来源
"""
ab_candidates: dict[str, list["Listing"]] = {u.id: [] for u, _ in user_notifiers}
status_transition: dict[str, tuple[str, str]] = {}
for listing in new_listings:
for user, notifier in user_notifiers:
if (
user.auto_book.enabled
and user.notifications_enabled
and notifier.has_channels
and listing.source == "holland2stay"
and listing.status.lower() == STATUS_AVAILABLE
and (user.auto_book.listing_filter.is_empty()
or user.auto_book.listing_filter.passes(listing))
):
ab_candidates[user.id].append(listing)
for listing, old_status, new_status in status_changes:
if new_status.lower() == STATUS_AVAILABLE:
status_transition[listing.id] = (old_status, new_status)
for user, notifier in user_notifiers:
if (
user.auto_book.enabled
and user.notifications_enabled
and notifier.has_channels
and listing.source == "holland2stay"
and new_status.lower() == STATUS_AVAILABLE
and (user.auto_book.listing_filter.is_empty()
or user.auto_book.listing_filter.passes(listing))
):
ab_candidates[user.id].append(listing)
# 重试队列检查:上次 race_lost 的候选,若仍 Available to book 则补入候选。
# 处理"前一个预订者未付款、房子被重新放出"但状态未变的场景:
# storage.diff() 对此类房源不产出任何事件,必须从重试队列中手动补入。
_fresh_avail = {
l.id: l
for l in fresh
if l.source == "holland2stay" and l.status.lower() == STATUS_AVAILABLE
}
for user, notifier in user_notifiers:
if not user.auto_book.enabled or not user.notifications_enabled or not notifier.has_channels:
continue
user_retry = retry_queue.get(user.id)
if not user_retry:
continue
gone = user_retry - _fresh_avail.keys()
if gone:
retry_queue.remove_gone(user.id, gone)
logger.info(
"[%s] 🗑️ %d 套 race_lost 房源已不可预订,移出重试队列",
user.name, len(gone),
)
existing_ids = {c.id for c in ab_candidates[user.id]}
for lid in user_retry & _fresh_avail.keys():
if lid in existing_ids:
continue # 已经由 status_changes 路径加入,跳过
listing = _fresh_avail[lid]
if user.auto_book.listing_filter.is_empty() or user.auto_book.listing_filter.passes(listing):
ab_candidates[user.id].append(listing)
logger.info(
"[%s] 🔁 重试 race_lost 房源(仍可预订): %s",
user.name, listing.name,
)
ab_candidates = _assign_auto_book_candidates(ab_candidates, user_notifiers)
return ab_candidates, status_transition
def _submit_bookings(
loop: asyncio.AbstractEventLoop,
ab_candidates: dict[str, list["Listing"]],
user_notifiers: "UserNotifiers",
prewarm_cached: dict[str, "PrewarmedSession"],
prewarm_futures: dict[str, "asyncio.Future"],
status_transition: dict[str, tuple[str, str]],
booking_deadline: float,
) -> list[tuple]:
"""把每个用户的候选 book_with_fallback() 立即提交线程池(快速下单通道)。
预订请求在发出通知之前就进入 Holland2Stay 服务器(节省 1-3 秒)。
Returns
-------
ab_futures: [(user, notifier, sorted_candidates, Future, prewarmed), ...]
sorted_candidates 按面积降序;fallback 逻辑在线程内按序尝试。
"""
ab_futures: list[tuple] = []
for user, notifier in user_notifiers:
candidates = ab_candidates.get(user.id, [])
if not (user.auto_book.enabled and candidates):
continue
suppressed = _h2s_login_suppressed_remaining()
if suppressed > 0:
logger.warning(
"[%s] 跳过 H2S 自动预订:登录/预订 403 抑制窗口仍剩 %d 秒",
user.name,
suppressed,
)
continue
# 取出该用户的预登录:优先命中缓存(同步),其次取已完成的刷新结果。
# 未完成的 future 不 await — 让 try_book() 走正常登录 fallback,
# 避免预登录网络延迟削弱"快速下单通道"。
prewarmed: PrewarmedSession | None = prewarm_cached.pop(user.id, None)
cache_hit = prewarmed is not None
if prewarmed is None:
pre_fut = prewarm_futures.pop(user.id, None)
if pre_fut is not None and pre_fut.done():
try:
prewarmed = pre_fut.result()
except BookingBlockedError as e:
_mark_h2s_login_blocked(e)
prewarmed = None
except Exception:
prewarmed = None
if prewarmed:
prewarm_cache.set(user.id, prewarmed)
elif pre_fut is not None:
# 仍在运行中,放回 futures 让 _stash_pending_prewarms 收尾
prewarm_futures[user.id] = pre_fut
suppressed = _h2s_login_suppressed_remaining()
if suppressed > 0:
logger.warning(
"[%s] 跳过 H2S 自动预订:prewarm 已确认 403,登录抑制窗口剩 %d 秒",
user.name,
suppressed,
)
continue
if prewarmed:
age = time.monotonic() - prewarmed.created_at
remaining = prewarmed.token_expiry - time.monotonic()
logger.info(
"[%s] ✅ 复用 prewarm(%s,已 %.0fs,剩余 %.0f 分钟)",
user.name, "缓存命中" if cache_hit else "新刷新",
age, remaining / 60,
)
else:
logger.info(
"[%s] ⚠️ 预登录未成功,下单时回退到正常登录路径",
user.name,
)
sorted_cands = sorted(candidates, key=area_key, reverse=True)
primary = sorted_cands[0]
if len(sorted_cands) > 1:
logger.info(
"[%s] 自动预订候选 %d 套(含 %d 套备选),优先面积最大: %s (%.1f m²)",
user.name, len(sorted_cands), len(sorted_cands) - 1,
primary.name, area_key(primary),
)
if primary.id in status_transition:
old_s, new_s = status_transition[primary.id]
logger.info(
"[%s] 🚀 快速预订通道 (%s → %s),立即提交到 executor: %s",
user.name, old_s, new_s, primary.name,
)
else:
logger.info(
"[%s] 🚀 自动预订(新上线可预订),立即提交到 executor: %s",
user.name, primary.name,
)
f = loop.run_in_executor(
None,
lambda cs=sorted_cands, u=user, pw=prewarmed:
book_with_fallback(cs, u, booking_deadline, prewarmed=pw),
)
ab_futures.append((user, notifier, sorted_cands, f, prewarmed))
return ab_futures
async def _notify_new_listings(
new_listings: list["Listing"],
user_notifiers: "UserNotifiers",
web_notifier: "WebNotifier | None",
storage: Storage,
push,
) -> tuple[int, list]:
"""发送新房源通知(预订已在后台线程并行运行)。
标记策略:任意用户通知成功即标记该 listing 为"已通知"。
Returns
-------
(total_notified, push_tasks)
push_tasks: 本段创建的 APNs/FCM asyncio.Task,由 run_once 末尾统一 gather。
"""
total_notified = 0
new_notified_ids: list[str] = []
user_round_matches: dict[str, list] = {} # user_id -> [Listing,...],用于聚合判定
push_tasks: list = []
for listing in new_listings:
notified_this = False
for user, notifier in user_notifiers:
if not user.listing_filter.is_empty() and not user.listing_filter.passes(listing):
logger.info("[%s] 跳过通知(过滤条件不符): %s", user.name, listing.name)
continue
logger.info("[%s] 新房源: %s (%s)", user.name, listing.name, listing.status)
ok = await notifier.send_new_listing(listing)
if ok:
notified_this = True
total_notified += 1
# APNs 推送钩子:现有渠道发送之后追加,独立 task,与其他渠道互不阻塞。
user_round_matches.setdefault(user.id, []).append(listing)
# Web 面板通知(每条新房源写一次,与用户过滤无关)
if web_notifier:
await web_notifier.send_new_listing(listing)
if notified_this:
new_notified_ids.append(listing.id)
# APNs + FCM 发送:本轮每个用户的匹配若 < 阈值,按条推;否则聚合成一条
if push.get_client() is not None or push.get_fcm_client() is not None:
round_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
user_by_id = {u.id: u for u, _ in user_notifiers}
for uid, matched in user_round_matches.items():
user_obj = user_by_id.get(uid)
if user_obj is None:
continue
if push.should_aggregate(len(matched)):
push_tasks.append(asyncio.create_task(
push.dispatch_aggregate(
storage, user_obj, matched, round_id=round_id,
),
))
else:
for l in matched:
push_tasks.append(asyncio.create_task(
push.dispatch(storage, user_obj, l, kind="new"),
))
storage.mark_notified_batch(new_notified_ids)
return total_notified, push_tasks
async def _notify_status_changes(
status_changes: list[tuple["Listing", str, str]],
user_notifiers: "UserNotifiers",
web_notifier: "WebNotifier | None",
storage: Storage,
push,
) -> list:
"""发送状态变更通知(预订已在后台线程并行运行)。
Returns
-------
push_tasks: 本段创建的 APNs/FCM asyncio.Task。
"""
push_tasks: list = []
sc_notified_ids: list[str] = []
for listing, old_status, new_status in status_changes:
notified_this = False
for user, notifier in user_notifiers:
if not user.listing_filter.is_empty() and not user.listing_filter.passes(listing):
logger.info("[%s] 状态变更跳过通知(过滤条件不符): %s", user.name, listing.name)
continue
logger.info("[%s] 状态变更: %s %s → %s", user.name, listing.name, old_status, new_status)
ok = await notifier.send_status_change(listing, old_status, new_status)
if ok:
notified_this = True
# APNs + FCM status_change:直接逐条推(变更通常不像新房源那么密集)
if push.get_client() is not None or push.get_fcm_client() is not None:
push_tasks.append(asyncio.create_task(
push.dispatch_status_change(
storage, user, listing, old_status, new_status,
),
))
# Web 面板通知(每次状态变更写一次,与用户过滤无关)
if web_notifier:
await web_notifier.send_status_change(listing, old_status, new_status)
if notified_this:
sc_notified_ids.append(listing.id)
storage.mark_status_change_notified_batch(sc_notified_ids)
return push_tasks
async def _process_booking_results(
ab_futures: list[tuple],
web_notifier: "WebNotifier | None",
storage: Storage,
push,
) -> list:
"""await 预订 Future,发送成功/失败通知,并聚合本轮屏蔽通知。
Returns
-------
push_tasks: 屏蔽聚合时给 admin 推的 asyncio.Task(可能为空)。
"""
push_tasks: list = []
# 本轮被屏蔽的用户(含 notifier),所有候选 await 完后聚合发一条节流通知,
# 避免每个用户/每个候选都发一次"预订失败"刷屏。
blocked_in_round: list[tuple[UserConfig, BaseNotifier, str]] = []
for user, notifier, sorted_cands, future, prewarmed in ab_futures:
result = await future
if result is None:
continue
# phase="blocked" 或 unknown_error 都意味着 session 可能已被 H2S 标记,
# 失效 prewarm 缓存让下轮换新 session+token+TLS 指纹。
if prewarmed and result.phase in ("unknown_error", "blocked"):
prewarm_cache.invalidate(user.id)
logger.info("[%s] 因 %s 失效 prewarm 缓存", user.name, result.phase)
# result.listing 是实际被尝试预订的那套房源(fallback 后可能不是 sorted_cands[0])
booked_listing = result.listing
# 更新重试队列(dry_run 不改变队列状态,避免污染正式运行时的数据)
if not result.dry_run:
if result.phase == "race_lost":
retry_queue.add(user.id, {c.id for c in sorted_cands})
logger.info(
"[%s] 📝 %d 套候选加入重试队列(下次扫描仍可预订时将重试)",
user.name, len(sorted_cands),
)
elif result.phase == "blocked":
# blocked 是 IP/指纹级,不是房源级问题;保留 retry_queue 状态不动,
# 等下轮换指纹后再决定是否重试。
_mark_h2s_login_blocked(result.message)
pass
else:
for c in sorted_cands:
retry_queue.discard(user.id, c.id)
if result.dry_run:
logger.info("[%s] [DRY RUN] 自动预订跳过: %s", user.name, booked_listing.name)
elif result.phase == "blocked":
# 不发 per-candidate booking_failed —— 累积到后面聚合一次性发
blocked_in_round.append((user, notifier, result.message))
elif result.success:
if storage.mark_listing_reserved_after_booking(booked_listing.id):
logger.info(
"[%s] 已将房源本地状态标记为 Reserved(booking hold): %s",
user.name, booked_listing.name,
)
sent = await notifier.send_booking_success(
booked_listing, result.message, result.pay_url, result.contract_start_date
)
if web_notifier:
await web_notifier.send_booking_success(
booked_listing,
result.message,
result.pay_url,
result.contract_start_date,
user_id=user.id,