PyPtt 2.3.10__tar.gz → 2.3.12__tar.gz

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 (111) hide show
  1. {pyptt-2.3.10 → pyptt-2.3.12}/PKG-INFO +1 -1
  2. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/PTT.py +1 -0
  3. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/__init__.py +1 -1
  4. pyptt-2.3.12/PyPtt/_api_bucket.py +80 -0
  5. pyptt-2.3.12/PyPtt/_api_get_board_info.py +325 -0
  6. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_favourite_board.py +18 -33
  7. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_newest_index.py +6 -0
  8. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_post_list.py +3 -0
  9. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_give_money.py +8 -4
  10. pyptt-2.3.12/PyPtt/_api_set_board_title.py +153 -0
  11. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_util.py +131 -1
  12. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/connect_core.py +74 -19
  13. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/screens.py +83 -7
  14. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt.egg-info/PKG-INFO +1 -1
  15. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt.egg-info/SOURCES.txt +3 -0
  16. pyptt-2.3.12/tests/test_board_setting_flow.py +468 -0
  17. pyptt-2.3.12/tests/test_get_board_info_parser.py +314 -0
  18. pyptt-2.3.12/tests/test_new_screen_fixes.py +406 -0
  19. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_set_board_title.py +5 -1
  20. pyptt-2.3.10/PyPtt/_api_bucket.py +0 -62
  21. pyptt-2.3.10/PyPtt/_api_get_board_info.py +0 -278
  22. pyptt-2.3.10/PyPtt/_api_set_board_title.py +0 -75
  23. {pyptt-2.3.10 → pyptt-2.3.12}/LICENSE +0 -0
  24. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_call_status.py +0 -0
  25. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_change_pw.py +0 -0
  26. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_comment.py +0 -0
  27. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_del_post.py +0 -0
  28. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_board_list.py +0 -0
  29. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_bottom_post_list.py +0 -0
  30. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_post.py +0 -0
  31. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_post_index.py +0 -0
  32. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_time.py +0 -0
  33. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_user.py +0 -0
  34. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_get_waterball.py +0 -0
  35. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_has_new_mail.py +0 -0
  36. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_loginout.py +0 -0
  37. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_mail.py +0 -0
  38. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_mark_post.py +0 -0
  39. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_post.py +0 -0
  40. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_reply_post.py +0 -0
  41. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_search_user.py +0 -0
  42. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_api_set_signature_file.py +0 -0
  43. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_uao/LICENSE +0 -0
  44. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_uao/__init__.py +0 -0
  45. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_uao/b2u.py +0 -0
  46. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/_uao/u2b.py +0 -0
  47. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/api_server.py +0 -0
  48. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/check_value.py +0 -0
  49. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/command.py +0 -0
  50. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/config.py +0 -0
  51. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/data_type.py +0 -0
  52. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/exceptions.py +0 -0
  53. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/i18n.py +0 -0
  54. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/lang_en_US.py +0 -0
  55. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/lang_zh_TW.py +0 -0
  56. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/lib_util.py +0 -0
  57. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/log.py +0 -0
  58. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/service.py +0 -0
  59. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt/ssl_config.py +0 -0
  60. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt.egg-info/dependency_links.txt +0 -0
  61. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt.egg-info/requires.txt +0 -0
  62. {pyptt-2.3.10 → pyptt-2.3.12}/PyPtt.egg-info/top_level.txt +0 -0
  63. {pyptt-2.3.10 → pyptt-2.3.12}/README.md +0 -0
  64. {pyptt-2.3.10 → pyptt-2.3.12}/pyproject.toml +0 -0
  65. {pyptt-2.3.10 → pyptt-2.3.12}/setup.cfg +0 -0
  66. {pyptt-2.3.10 → pyptt-2.3.12}/setup.py +0 -0
  67. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_backup_effective.py +0 -0
  68. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_backup_param.py +0 -0
  69. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_bucket.py +0 -0
  70. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_change_pw.py +0 -0
  71. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_comment.py +0 -0
  72. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_connect_core_timeout.py +0 -0
  73. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_del_post.py +0 -0
  74. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_del_post_unit.py +0 -0
  75. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_exceptions.py +0 -0
  76. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_fast_post.py +0 -0
  77. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_board_info.py +0 -0
  78. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_board_list.py +0 -0
  79. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_bottom_post_list.py +0 -0
  80. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_content_formula.py +0 -0
  81. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_favourite_boards.py +0 -0
  82. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_mail.py +0 -0
  83. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_newest_index.py +0 -0
  84. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_post.py +0 -0
  85. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_post_list.py +0 -0
  86. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_post_list_parser.py +0 -0
  87. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_post_parser.py +0 -0
  88. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_time.py +0 -0
  89. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_user.py +0 -0
  90. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_get_waterball.py +0 -0
  91. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_give_money.py +0 -0
  92. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_half_dead_connection.py +0 -0
  93. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_i18n.py +0 -0
  94. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_incremental_parity.py +0 -0
  95. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_init.py +0 -0
  96. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_logger.py +0 -0
  97. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_login_logout.py +0 -0
  98. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_mail_send_and_del.py +0 -0
  99. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_main_menu_status_bar.py +0 -0
  100. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_mark_post.py +0 -0
  101. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_parse_query_post.py +0 -0
  102. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_post.py +0 -0
  103. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_post_anonymous.py +0 -0
  104. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_pyte_parity.py +0 -0
  105. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_reply_post.py +0 -0
  106. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_search_user.py +0 -0
  107. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_service.py +0 -0
  108. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_set_signature_file.py +0 -0
  109. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_status_line.py +0 -0
  110. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_uao_codec.py +0 -0
  111. {pyptt-2.3.10 → pyptt-2.3.12}/tests/test_vt100_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.3.10
3
+ Version: 2.3.12
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -586,6 +586,7 @@ class API:
586
586
  UnregisteredUser: 未註冊使用者。
587
587
  NoSuchUser: 使用者不存在。
588
588
  NoMoney: 餘額不足。
589
+ ParameterError: 金額過少,交易取消。
589
590
 
590
591
  範例::
591
592
 
@@ -1,4 +1,4 @@
1
- __version__ = '2.3.10'
1
+ __version__ = '2.3.12'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -0,0 +1,80 @@
1
+ import time
2
+
3
+ from . import _api_set_board_title
4
+ from . import _api_util
5
+ from . import check_value
6
+ from . import command
7
+ from . import exceptions
8
+ from . import i18n
9
+ from . import lib_util
10
+ from . import screens
11
+ from ._api_set_board_title import step
12
+
13
+
14
+ def _bucket_days_max() -> int:
15
+ # pttbbs mbbsd/acl.c:180-206: 期限輸入欄 getdata(..., datebuf, 8) 最多 7 位數,
16
+ # 到期時間用 time4_add_sec() 算,超過 0xFFFFFFFF (2106-02-07) 就回「日期過大」
17
+ # (include/cmsys.h:86-93)。所以上限是「現在到 2106-02-07」的整天數。
18
+ return min(9999999, (0xFFFFFFFF - int(time.time())) // 86400 - 1)
19
+
20
+
21
+ def bucket(api, board: str, bucket_days: int, reason: str, ptt_id: str) -> None:
22
+ _api_util.one_thread(api)
23
+
24
+ if not api._is_login:
25
+ raise exceptions.RequireLogin(i18n.require_login)
26
+
27
+ if not api.is_registered_user:
28
+ raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
29
+
30
+ check_value.check_type(board, str, 'board')
31
+ check_value.check_type(bucket_days, int, 'bucket_days')
32
+ check_value.check_type(reason, str, 'reason')
33
+ check_value.check_type(ptt_id, str, 'ptt_id')
34
+ # bool 是 int 的子類別;期限要是正整數天,且不超過 PTT 能存的到期時間
35
+ max_days = _bucket_days_max()
36
+ if isinstance(bucket_days, bool) or not 0 < bucket_days <= max_days:
37
+ raise exceptions.ParameterError(f'bucket_days must be an int in 1..{max_days}, got {bucket_days!r}')
38
+ _api_util.check_no_control_chars(reason, 'reason')
39
+ _api_util.check_no_control_chars(ptt_id, 'ptt_id')
40
+
41
+ _api_util.check_user_exist(api, ptt_id)
42
+
43
+ _api_util.check_board(api, board, check_moderator=True)
44
+
45
+ _api_util.goto_board(api, board)
46
+
47
+ # 從這裡起進入看板設定;任何結束路徑都由 run_in_board_setting 退回文章列表
48
+ _api_util.run_in_board_setting(api, lambda: _bucket_in_board_setting(api, board, bucket_days, reason, ptt_id))
49
+
50
+
51
+ def _bucket_in_board_setting(api, board: str, bucket_days: int, reason: str, ptt_id: str) -> None:
52
+
53
+ # 新版編輯模式裡 w 直接打開 Bakuman (不能再補 Enter,Enter 會被當成 [Q]結束);
54
+ # 舊版 w 是「請輸入要改變的設定」單行輸入,要 Enter 送出。
55
+ mode = _api_set_board_title.open_board_setting(api, board, 'i')
56
+ step(api, 'w' if mode == 'new' else 'w' + command.enter, [('bakuman', screens.Target.Bakuman)])
57
+
58
+ # Bakuman 新舊版相同,每一步都比對提示再送下一步
59
+ step(api, 'a' + command.enter, [('id', '請輸入使用者代號')])
60
+ result = step(api, ptt_id + command.enter, [
61
+ ('period', '請以數字跟單位(預設為天)輸入期限'),
62
+ ('banned', '◆ 使用者之前已被禁言'),
63
+ ])
64
+ if result == 'banned':
65
+ raise exceptions.UserHasPreviouslyBeenBanned()
66
+
67
+ step(api, str(bucket_days) + command.enter, [('reason', '請輸入理由')])
68
+ step(api, reason + command.enter, [('confirm', '確認以上資料全部正確嗎')])
69
+ step(api, 'y' + command.enter, [
70
+ ('done', '◆ 已將使用者加入禁言名單'),
71
+ ], screen_timeout=api.config.screen_long_timeout)
72
+
73
+ # [按任意鍵繼續] → 回到 [Q]結束? → Enter (空白 = Q) 離開 Bakuman
74
+ step(api, command.left, [('bakuman', screens.Target.Bakuman)])
75
+ if mode == 'new':
76
+ step(api, command.enter, [('edit', screens.Target.BoardSettingEdit)])
77
+ else:
78
+ step(api, command.enter, [('prompt', screens.Target.BoardSettingOldPrompt)])
79
+
80
+ _api_set_board_title.close_board_setting(api, mode)
@@ -0,0 +1,325 @@
1
+ import re
2
+ from typing import Dict
3
+
4
+ from . import _api_util
5
+ from . import check_value
6
+ from . import command
7
+ from . import connect_core
8
+ from . import exceptions
9
+ from . import i18n
10
+ from . import log
11
+ from . import screens
12
+ from .data_type import BoardField
13
+
14
+
15
+ def get_board_info(api, board: str, get_post_kind: bool, call_by_others: bool) -> Dict:
16
+ logger = log.init(log.DEBUG if call_by_others else log.INFO)
17
+
18
+ _api_util.one_thread(api)
19
+
20
+ if not api._is_login:
21
+ raise exceptions.RequireLogin(i18n.require_login)
22
+
23
+ check_value.check_type(board, str, 'board')
24
+
25
+ logger.info(
26
+ i18n.replace(i18n.get_board_info, board))
27
+
28
+ _api_util.goto_board(api, board, refresh=True)
29
+
30
+ # last_timeout_was_silent is only meaningful right after a send(), and
31
+ # goto_board()'s last action is one. Nothing below sends until the 'i'
32
+ # further down, so the flag stays valid for the whole parse block: a
33
+ # totally silent timeout means goto_board never redrew and we are about
34
+ # to parse a stale screen -- a half-dead connection, not a missing board.
35
+ ori_screen = api.connect_core.get_screen_queue()[-1]
36
+ # print(ori_screen)
37
+
38
+ online_user_line = None
39
+ for line in ori_screen.split('\n'):
40
+ if '作 者' not in line:
41
+ continue
42
+ if '日 期' not in line:
43
+ continue
44
+
45
+ online_user_line = line
46
+ break
47
+
48
+ if online_user_line is None:
49
+ if api.connect_core.last_timeout_was_silent:
50
+ raise exceptions.ConnectionClosed()
51
+ raise exceptions.NoSuchBoard(api.config, board)
52
+
53
+ if '[靜]' in online_user_line:
54
+ online_user = 0
55
+ else:
56
+ if '日 期' not in online_user_line or '作 者' not in online_user_line:
57
+ if api.connect_core.last_timeout_was_silent:
58
+ raise exceptions.ConnectionClosed()
59
+ raise exceptions.NoSuchBoard(api.config, board)
60
+ pattern = re.compile(r'[\d]+')
61
+ r = pattern.search(online_user_line)
62
+ if r is None:
63
+ if api.connect_core.last_timeout_was_silent:
64
+ raise exceptions.ConnectionClosed()
65
+ raise exceptions.NoSuchBoard(api.config, board)
66
+ # 減一是把自己本身拿掉
67
+ online_user = int(r.group(0)) - 1
68
+
69
+ logger.debug('人氣', online_user)
70
+
71
+ target_list = [
72
+ # 新版「看板設定與管理」畫面沒有任意鍵提示;等頁尾畫完再解析
73
+ connect_core.TargetUnit('(PgUp)上頁 (PgDn)下頁', log_level=log.DEBUG if call_by_others else log.INFO,
74
+ break_detect=True),
75
+ connect_core.TargetUnit('任意鍵繼續', log_level=log.DEBUG if call_by_others else log.INFO,
76
+ break_detect=True),
77
+ ]
78
+
79
+ api.connect_core.send(
80
+ 'i',
81
+ target_list)
82
+
83
+ # Same flag window as above: the 'i' send() right before is the last one
84
+ # until the get_post_kind block, so the flag still describes it here.
85
+ ori_screen = api.connect_core.get_screen_queue()[-1]
86
+ # print(ori_screen)
87
+
88
+ # 新版「看板設定與管理」沒有任意鍵提示,會一直停在選單上 (之後 API 開頭的 Space
89
+ # 會落在選單裡)。畫面已存下來,先用一個 ← 退回文章列表再解析;確認不了就斷線。
90
+ if screens.in_board_setting(ori_screen):
91
+ api.connect_core.send(command.left, [
92
+ connect_core.TargetUnit(screens.Target.InBoard, log_level=log.DEBUG, break_detect=True)])
93
+ if not _api_util.is_article_list(api, api.connect_core.get_screen_queue()[-1]):
94
+ _api_util.close_session(api)
95
+ raise exceptions.ConnectionClosed()
96
+
97
+ boardname = None
98
+ # 新版: '看板名稱: Test 板主名單: ...';舊版: '《Test》看板設定'
99
+ r = re.search(r'看板名稱: (\S+)|《(.+)》看板設定', ori_screen)
100
+ if r is not None:
101
+ boardname = (r.group(1) or r.group(2)).strip()
102
+
103
+ if boardname is None:
104
+ if api.connect_core.last_timeout_was_silent:
105
+ raise exceptions.ConnectionClosed()
106
+ raise exceptions.NoSuchBoard(api.config, board)
107
+ logger.debug('看板名稱', boardname, board)
108
+
109
+ if boardname.lower() != board.lower():
110
+ if api.connect_core.last_timeout_was_silent:
111
+ raise exceptions.ConnectionClosed()
112
+ raise exceptions.NoSuchBoard(api.config, board)
113
+
114
+ chinese_des = ''
115
+ p = re.compile(r'中文敘述: (.+)')
116
+ r = p.search(ori_screen)
117
+ if r is not None:
118
+ chinese_des = r.group(0)[5:].strip()
119
+ logger.debug('中文敘述', chinese_des)
120
+
121
+ moderators = []
122
+ p = re.compile(r'板主名單: (.+)')
123
+ r = p.search(ori_screen)
124
+ if r is not None:
125
+ moderator_line = r.group(0)[5:].strip()
126
+ if '(無)' in moderator_line:
127
+ moderators = []
128
+ else:
129
+ moderators = moderator_line.split('/')
130
+ for moderator in moderators.copy():
131
+ if moderator in ('徵求中', ''):
132
+ moderators.remove(moderator)
133
+ logger.debug('板主名單', moderators)
134
+
135
+ if '看板名稱:' in ori_screen:
136
+ # 新版畫面每項一列: ' 9. 快速連推文章 限制 (間隔 5 秒)'。
137
+ # 2026-10-19 起欄寬變寬;編輯模式另有熱鍵標示 ' 1. (b)中文敘述' 與
138
+ # 權限註記 '開放 (限站長)',兩者都去掉再比對。
139
+ # 值要整串比對:'開放' 是 '不開放' 的子字串,'對齊' 是 '不用對齊' 的子字串。
140
+ def value(label: str) -> str:
141
+ m = re.search(r'^[> ]*\d+\. +(?:\(\w\))?' + re.escape(label) + r' +(.+?) *$', ori_screen, re.M)
142
+ return re.sub(r' *\(限[^)]*\)$', '', m.group(1)) if m else ''
143
+
144
+ chinese_des = value('中文敘述')
145
+ open_status = value('公開狀態 (是否隱形)') == '公開'
146
+ into_top_ten_when_hide = value('隱板時進入十大排行榜') == '可以'
147
+ non_board_members_post = value('非看板會員發文') == '開放'
148
+ reply_post = value('回應文章') == '開放'
149
+ self_del_post = value('自刪文章') == '開放'
150
+ push_post = value('推薦文章 (推文)') == '開放'
151
+ boo_post = value('噓文') == '開放'
152
+ fast_push = value('快速連推文章') == '開放'
153
+ m = re.search(r'間隔 (\d+) 秒', value('快速連推文章'))
154
+ min_interval = int(m.group(1)) if m else 0
155
+ push_record_ip = value('推文時記錄來源 IP') == '自動記錄'
156
+ push_aligned = value('推文開頭自動對齊') == '對齊'
157
+ moderator_can_del_illegal_content = value('板主刪除部份違規文字') == '可'
158
+ tran_post_auto_recorded_and_require_post_permissions = value('轉錄文章自動記錄') == '會 (需發文權限)'
159
+ cool_mode = value('冷靜模式') != '未設定'
160
+ require18 = value('未滿十八歲進入') == '禁止'
161
+ # 2026-10-19 起有發文條件兩列:
162
+ # '16. [發文條件] 登入次數 500 次以上 (您: 6614 次)'
163
+ # '17. [發文條件] 退文篇數 1 篇以下 (您: 0 篇)'
164
+ # '無限制' 或沒有這兩列 (2026-10-18 的畫面) 都給 0 (= 無限制)
165
+ m = re.match(r'(\d+) 次以上', value('[發文條件] 登入次數'))
166
+ require_login_time = int(m.group(1)) if m else 0
167
+ m = re.match(r'(\d+) 篇以下', value('[發文條件] 退文篇數'))
168
+ require_illegal_post = int(m.group(1)) if m else 0
169
+ else:
170
+ open_status = ('公開狀態(是否隱形): 公開' in ori_screen)
171
+ logger.debug('公開狀態', open_status)
172
+
173
+ into_top_ten_when_hide = ('隱板時 可以 進入十大排行榜' in ori_screen)
174
+ logger.debug('隱板時可以進入十大排行榜', into_top_ten_when_hide)
175
+
176
+ non_board_members_post = ('開放 非看板會員發文' in ori_screen)
177
+ logger.debug('非看板會員發文', non_board_members_post)
178
+
179
+ reply_post = ('開放 回應文章' in ori_screen)
180
+ logger.debug('回應文章', reply_post)
181
+
182
+ self_del_post = ('開放 自刪文章' in ori_screen)
183
+ logger.debug('自刪文章', self_del_post)
184
+
185
+ push_post = ('開放 推薦文章' in ori_screen)
186
+ logger.debug('推薦文章', push_post)
187
+
188
+ boo_post = ('開放 噓文' in ori_screen)
189
+ logger.debug('噓文', boo_post)
190
+
191
+ # 限制 快速連推文章, 最低間隔時間: 5 秒
192
+ # 開放 快速連推文章
193
+
194
+ fast_push = ('開放 快速連推文章' in ori_screen)
195
+ logger.debug('快速連推文章', fast_push)
196
+
197
+ if not fast_push:
198
+ p = re.compile(r'最低間隔時間: [\d]+')
199
+ r = p.search(ori_screen)
200
+ if r is not None:
201
+ min_interval = r.group(0)[7:].strip()
202
+ min_interval = int(min_interval)
203
+ else:
204
+ min_interval = 0
205
+ logger.debug('最低間隔時間', min_interval)
206
+ else:
207
+ min_interval = 0
208
+
209
+ # 推文時 自動 記錄來源 IP
210
+ # 推文時 不會 記錄來源 IP
211
+ push_record_ip = ('推文時 自動 記錄來源 IP' in ori_screen)
212
+ logger.debug('記錄來源 IP', push_record_ip)
213
+
214
+ # 推文時 對齊 開頭
215
+ # 推文時 不用對齊 開頭
216
+ push_aligned = ('推文時 對齊 開頭' in ori_screen)
217
+ logger.debug('對齊開頭', push_aligned)
218
+
219
+ # 板主 可 刪除部份違規文字
220
+ moderator_can_del_illegal_content = ('板主 可 刪除部份違規文字' in ori_screen)
221
+ logger.debug('板主可刪除部份違規文字', moderator_can_del_illegal_content)
222
+
223
+ # 轉錄文章 會 自動記錄,且 需要 發文權限
224
+ tran_post_auto_recorded_and_require_post_permissions = ('轉錄文章 會 自動記錄,且 需要 發文權限' in ori_screen)
225
+ logger.debug('轉錄文章 會 自動記錄,且 需要 發文權限', tran_post_auto_recorded_and_require_post_permissions)
226
+
227
+ cool_mode = ('未 設為冷靜模式' not in ori_screen)
228
+ logger.debug('冷靜模式', cool_mode)
229
+
230
+ require18 = ('禁止 未滿十八歲進入' in ori_screen)
231
+ logger.debug('禁止未滿十八歲進入', require18)
232
+
233
+ p = re.compile(r'登入次數 [\d]+ 次以上')
234
+ r = p.search(ori_screen)
235
+ if r is not None:
236
+ require_login_time = r.group(0).split(' ')[1]
237
+ require_login_time = int(require_login_time)
238
+ else:
239
+ require_login_time = 0
240
+ logger.debug('發文限制登入次數', require_login_time)
241
+
242
+ p = re.compile(r'退文篇數 [\d]+ 篇以下')
243
+ r = p.search(ori_screen)
244
+ if r is not None:
245
+ require_illegal_post = r.group(0).split(' ')[1]
246
+ require_illegal_post = int(require_illegal_post)
247
+ else:
248
+ require_illegal_post = 0
249
+ logger.debug('發文限制退文篇數', require_illegal_post)
250
+
251
+ kind_list = []
252
+ if get_post_kind:
253
+
254
+ _api_util.goto_board(api, board)
255
+
256
+ # Go certain board, then post to get post type info
257
+ cmd_list = []
258
+ cmd_list.append(command.ctrl_p)
259
+ cmd = ''.join(cmd_list)
260
+
261
+ target_list = [
262
+ connect_core.TargetUnit('無法發文: 未達看板要求權限', break_detect=True),
263
+ connect_core.TargetUnit('或不選)', break_detect=True)
264
+ ]
265
+
266
+ index = api.connect_core.send(
267
+ cmd,
268
+ target_list)
269
+
270
+ if index == 0:
271
+ raise exceptions.NoPermission(i18n.no_permission)
272
+ # no post permission
273
+
274
+ ori_screen = api.connect_core.get_screen_queue()[-1]
275
+ screen_lines = ori_screen.split('\n')
276
+
277
+ for i in screen_lines:
278
+ if '種類:' in i:
279
+ type_pattern = re.compile(r'\d\.([^\ ]*)')
280
+ # 0 is not present any type that the key hold None object
281
+ kind_list = type_pattern.findall(i)
282
+ break
283
+
284
+ # Clear post status
285
+ cmd_list = []
286
+ cmd_list.append(command.ctrl_c)
287
+ cmd_list.append(command.ctrl_c)
288
+ cmd = ''.join(cmd_list)
289
+
290
+ target_list = [
291
+ connect_core.TargetUnit(screens.Target.InBoard, break_detect=True)
292
+ ]
293
+ api.connect_core.send(
294
+ cmd,
295
+ target_list)
296
+
297
+ logger.info(
298
+ i18n.replace(i18n.get_board_info, board),
299
+ '...', i18n.success
300
+ )
301
+
302
+ return {
303
+ BoardField.board: boardname,
304
+ BoardField.online_user: online_user,
305
+ BoardField.mandarin_des: chinese_des,
306
+ BoardField.moderators: moderators,
307
+ BoardField.open_status: open_status,
308
+ BoardField.into_top_ten_when_hide: into_top_ten_when_hide,
309
+ BoardField.can_non_board_members_post: non_board_members_post,
310
+ BoardField.can_reply_post: reply_post,
311
+ BoardField.self_del_post: self_del_post,
312
+ BoardField.can_comment_post: push_post,
313
+ BoardField.can_boo_post: boo_post,
314
+ BoardField.can_fast_push: fast_push,
315
+ BoardField.min_interval_between_comments: min_interval,
316
+ BoardField.is_comment_record_ip: push_record_ip,
317
+ BoardField.is_comment_aligned: push_aligned,
318
+ BoardField.can_moderators_del_illegal_content: moderator_can_del_illegal_content,
319
+ BoardField.does_tran_post_auto_recorded_and_require_post_permissions: tran_post_auto_recorded_and_require_post_permissions,
320
+ BoardField.is_cool_mode: cool_mode,
321
+ BoardField.is_require18: require18,
322
+ BoardField.require_login_time: require_login_time,
323
+ BoardField.require_illegal_post: require_illegal_post,
324
+ BoardField.post_kind_list: kind_list
325
+ }
@@ -1,11 +1,17 @@
1
+ import re
2
+
1
3
  from . import _api_util
2
4
  from . import command
3
5
  from . import connect_core
4
6
  from . import exceptions
5
7
  from . import i18n
6
8
  from . import log
9
+ from . import screens
7
10
  from .data_type import FavouriteBoardField
8
11
 
12
+ # ' 83) MCP_Test 測試 ◎…'、' 2 ˇAllTogether 聯誼 ◎…':編號、[)]、[ˇ]、板名、類別、◎標題
13
+ _ROW = re.compile(r'\s*\d+\)?\s+ˇ?\s*(\S+)\s+(\S+)\s*(.*)')
14
+
9
15
 
10
16
  def get_favourite_board(api) -> list:
11
17
  _api_util.one_thread(api)
@@ -17,7 +23,8 @@ def get_favourite_board(api) -> list:
17
23
  cmd = ''.join(cmd_list)
18
24
 
19
25
  target_list = [
20
- connect_core.TargetUnit('選擇看板', break_detect=True)
26
+ # 舊版頁尾有「選擇看板」,新版沒有;標頭與頁尾的「已讀/未讀」新舊都有
27
+ connect_core.TargetUnit(['【看板列表】', '已讀/未讀'], break_detect=True)
21
28
  ]
22
29
 
23
30
  log.logger.info(i18n.get_favourite_board_list)
@@ -32,42 +39,20 @@ def get_favourite_board(api) -> list:
32
39
  target_list)
33
40
 
34
41
  ori_screen = api.connect_core.get_screen_queue()[-1]
35
- # print(OriScreen)
36
- screen_buf = ori_screen
37
- screen_buf = [x for x in screen_buf.split('\n')][3:-1]
38
-
39
- # adjust for cursor
40
- screen_buf[0] = ' ' + screen_buf[0][1:]
41
- screen_buf = [x for x in screen_buf]
42
-
43
- min_len = 47
44
-
45
- for i, line in enumerate(screen_buf):
46
- if len(screen_buf[i]) == 0:
47
- continue
48
- if len(screen_buf[i]) <= min_len:
49
- # print(f'[{ScreenBuf[i]}]')
50
- screen_buf[i] = screen_buf[i] + (' ' * ((min_len + 1) - len(screen_buf[i])))
51
- screen_buf = [x[10:min_len - len(x)].strip() for x in screen_buf]
52
- screen_buf = list(filter(None, screen_buf))
42
+ screen_buf = [x for x in ori_screen.split('\n')[3:-1] if x.strip()]
53
43
 
54
- for i, line in enumerate(screen_buf):
44
+ for line in screen_buf:
55
45
  if '------------' in line:
56
46
  continue
57
-
58
- temp = line.strip().split(' ')
59
- no_space_temp = list(filter(None, temp))
60
-
61
- board = no_space_temp[0]
62
- if board.startswith('ˇ'):
63
- board = board[1:]
64
-
65
- board_type = no_space_temp[1]
66
-
67
- title_start_index = temp.index(board_type) + 1
68
- board_title = ' '.join(temp[title_start_index:])
47
+ if line.startswith(api.cursor):
48
+ line = ' ' * screens._cell_len(api.cursor) + line[len(api.cursor):]
49
+ # 板名、類別依欄位內容切;標題與人氣欄之間沒有分隔,只能在第 64 格 (人氣欄起點) 切掉
50
+ m = _ROW.match(line[:screens._str_pos_at_cells(line, 64)])
51
+ if m is None:
52
+ continue
53
+ board, board_type = m.group(1), m.group(2)
69
54
  # remove ◎
70
- board_title = board_title[1:]
55
+ board_title = m.group(3)[1:].strip()
71
56
 
72
57
  if board in board_list:
73
58
  log.logger.info(i18n.get_favourite_board_list, '...', i18n.success)
@@ -119,6 +119,12 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
119
119
  screens.Target.InBoardWithCursor,
120
120
  log_level=log.DEBUG,
121
121
  break_detect=True))
122
+ # 新版搜尋無結果時,後面的 '1\r' 會打開游標所在的文章;落在文章裡就是沒有結果
123
+ # 游標文是動畫文時會先停在「要開始播放嗎?」;同樣是沒有結果 (畫面留在詢問上,由下一支 API 的導航離開)
124
+ target_list.append(connect_core.TargetUnit(screens.Target.InPost,
125
+ exceptions_=exceptions.NoSearchResult()))
126
+ target_list.append(connect_core.TargetUnit(screens.Target.Animation,
127
+ exceptions_=exceptions.NoSearchResult()))
122
128
 
123
129
  cmd_list = _api_util.get_search_condition_cmd(index_type, search_list)
124
130
 
@@ -54,6 +54,9 @@ def get_post_list(api, board: str, limit: int = 20, offset: int = 0) -> List[Dic
54
54
 
55
55
  post_list = []
56
56
 
57
+ # get_newest_index 命中 2 秒快取時不會導航,畫面可能還停在上一支 API 留下的地方 (例如 get_post 的文章內)
58
+ _api_util.goto_board(api, board)
59
+
57
60
  with _api_util.expanded_screen(api):
58
61
  for _ in range(max_round):
59
62
 
@@ -71,7 +71,8 @@ def give_money(api, ptt_id: str, money: int, red_bag_title: str, red_bag_content
71
71
 
72
72
  target_list = [
73
73
  connect_core.TargetUnit('你沒有那麼多Ptt幣喔!', break_detect=True, exceptions_=exceptions.NoMoney()),
74
- connect_core.TargetUnit('金額過少,交易取消!', break_detect=True, exceptions_=exceptions.NoMoney()),
74
+ connect_core.TargetUnit('金額過少,交易取消!', break_detect=True,
75
+ exceptions_=exceptions.ParameterError(f'money is too small: {money}')),
75
76
  connect_core.TargetUnit('交易取消!', break_detect=True,
76
77
  exceptions_=exceptions.UnknownError(i18n.transaction_cancelled)),
77
78
  connect_core.TargetUnit('確定進行交易嗎?', response='y' + command.enter),
@@ -80,10 +81,13 @@ def give_money(api, ptt_id: str, money: int, red_bag_title: str, red_bag_content
80
81
  connect_core.TargetUnit('要修改紅包袋嗎', response=command.enter),
81
82
  connect_core.TargetUnit('完成交易前要重新確認您的身份', response=api._ptt_pw + command.enter, secret=True),
82
83
  connect_core.TargetUnit('他是你的小主人,是否匿名?', response='n' + command.enter),
83
- connect_core.TargetUnit('要給他多少Ptt幣呢?', response=command.tab + str(money) + command.enter),
84
- connect_core.TargetUnit('這位幸運兒的id', response=ptt_id + command.enter),
84
+ # 金額那行在後續畫面都還在,所以後續的確認提示要排在「幣呢?」前面,否則金額會被打在 y/n 提示上
85
85
  connect_core.TargetUnit('認證尚未過期', response='y' + command.enter),
86
- connect_core.TargetUnit('交易正在進行中', response=command.space)
86
+ connect_core.TargetUnit('交易正在進行中', response=command.space),
87
+ # 舊版「要給他多少Ptt幣呢?」、新版「要給多少Ptt幣呢?」(docker 站是 Dtt幣)。要排在「這位幸運兒的id」
88
+ # 前面,否則同畫面的 id 行先被對上,id 又被送進金額欄。TAB 切到「對方收到 (稅後)」,與舊版相同
89
+ connect_core.TargetUnit('幣呢?', response=command.tab + str(money) + command.enter),
90
+ connect_core.TargetUnit('這位幸運兒的id', response=ptt_id + command.enter),
87
91
  ]
88
92
 
89
93
  api.connect_core.send(