PyPtt 2.1.4__tar.gz → 2.1.6__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 (93) hide show
  1. {pyptt-2.1.4 → pyptt-2.1.6}/PKG-INFO +2 -1
  2. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/PTT.py +17 -3
  3. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/__init__.py +1 -1
  4. pyptt-2.1.6/PyPtt/_api_del_post.py +258 -0
  5. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_bottom_post_list.py +1 -1
  6. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_newest_index.py +1 -0
  7. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_post.py +1 -0
  8. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_user.py +12 -4
  9. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_mail.py +15 -0
  10. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/connect_core.py +4 -1
  11. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/data_type.py +14 -0
  12. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/exceptions.py +8 -0
  13. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/lang_en_US.py +3 -1
  14. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/lang_zh_TW.py +2 -0
  15. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/log.py +27 -2
  16. pyptt-2.1.6/PyPtt/ssl_config.py +24 -0
  17. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt.egg-info/PKG-INFO +2 -1
  18. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt.egg-info/SOURCES.txt +1 -0
  19. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt.egg-info/requires.txt +1 -0
  20. {pyptt-2.1.4 → pyptt-2.1.6}/pyproject.toml +1 -0
  21. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_del_post.py +134 -0
  22. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_exceptions.py +1 -0
  23. pyptt-2.1.6/tests/test_get_mail.py +116 -0
  24. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_newest_index.py +18 -0
  25. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_post.py +111 -0
  26. pyptt-2.1.6/tests/test_get_post_list.py +101 -0
  27. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_i18n.py +20 -0
  28. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_logger.py +19 -1
  29. pyptt-2.1.6/tests/test_login_logout.py +65 -0
  30. pyptt-2.1.4/PyPtt/_api_del_post.py +0 -138
  31. pyptt-2.1.4/PyPtt/ssl_config.py +0 -24
  32. pyptt-2.1.4/tests/test_get_mail.py +0 -40
  33. pyptt-2.1.4/tests/test_get_post_list.py +0 -71
  34. {pyptt-2.1.4 → pyptt-2.1.6}/LICENSE +0 -0
  35. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_bucket.py +0 -0
  36. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_call_status.py +0 -0
  37. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_change_pw.py +0 -0
  38. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_comment.py +0 -0
  39. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_board_info.py +0 -0
  40. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_board_list.py +0 -0
  41. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_favourite_board.py +0 -0
  42. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_post_index.py +0 -0
  43. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_post_list.py +0 -0
  44. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_time.py +0 -0
  45. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_get_waterball.py +0 -0
  46. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_give_money.py +0 -0
  47. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_has_new_mail.py +0 -0
  48. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_loginout.py +0 -0
  49. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_mark_post.py +0 -0
  50. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_post.py +0 -0
  51. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_reply_post.py +0 -0
  52. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_search_user.py +0 -0
  53. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_set_board_title.py +0 -0
  54. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/_api_util.py +0 -0
  55. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/api_server.py +0 -0
  56. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/check_value.py +0 -0
  57. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/command.py +0 -0
  58. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/config.py +0 -0
  59. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/i18n.py +0 -0
  60. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/lib_util.py +0 -0
  61. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/screens.py +0 -0
  62. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt/service.py +0 -0
  63. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt.egg-info/dependency_links.txt +0 -0
  64. {pyptt-2.1.4 → pyptt-2.1.6}/PyPtt.egg-info/top_level.txt +0 -0
  65. {pyptt-2.1.4 → pyptt-2.1.6}/README.md +0 -0
  66. {pyptt-2.1.4 → pyptt-2.1.6}/setup.cfg +0 -0
  67. {pyptt-2.1.4 → pyptt-2.1.6}/setup.py +0 -0
  68. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_bucket.py +0 -0
  69. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_change_pw.py +0 -0
  70. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_comment.py +0 -0
  71. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_fast_post.py +0 -0
  72. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_board_info.py +0 -0
  73. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_board_list.py +0 -0
  74. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_bottom_post_list.py +0 -0
  75. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_content_formula.py +0 -0
  76. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_favourite_boards.py +0 -0
  77. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_post_list_parser.py +0 -0
  78. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_post_parser.py +0 -0
  79. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_time.py +0 -0
  80. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_user.py +0 -0
  81. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_get_waterball.py +0 -0
  82. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_give_money.py +0 -0
  83. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_init.py +0 -0
  84. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_mail_send_and_del.py +0 -0
  85. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_mark_post.py +0 -0
  86. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_parse_query_post.py +0 -0
  87. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_post.py +0 -0
  88. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_pyte_parity.py +0 -0
  89. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_reply_post.py +0 -0
  90. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_search_user.py +0 -0
  91. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_service.py +0 -0
  92. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_set_board_title.py +0 -0
  93. {pyptt-2.1.4 → pyptt-2.1.6}/tests/test_vt100_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.1.4
3
+ Version: 2.1.6
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)
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: progressbar2
29
29
  Requires-Dist: websockets>=12
30
+ Requires-Dist: certifi
30
31
  Requires-Dist: uao
31
32
  Requires-Dist: requests>=2.32
32
33
  Requires-Dist: AutoStrEnum
@@ -1071,7 +1071,9 @@ class API:
1071
1071
 
1072
1072
  return _api_get_bottom_post_list.get_bottom_post_list(self, board)
1073
1073
 
1074
- def del_post(self, board: str, aid: Optional[str] = None, index: int = 0, reason: Optional[str] = None) -> None:
1074
+ def del_post(self, board: str, aid: Optional[str] = None, index: int = 0, reason: Optional[str] = None,
1075
+ bad_post_type: Optional[data_type.BadPostType] = None,
1076
+ bad_post_reason: Optional[str] = None) -> None:
1075
1077
  """
1076
1078
  刪除文章。
1077
1079
 
@@ -1080,6 +1082,10 @@ class API:
1080
1082
  aid (str): 文章編號。
1081
1083
  index (int): 文章編號。
1082
1084
  reason (str): 板主刪除他人文章時,加註於刪除後標題的理由(僅板主刪除他板友文章時有效)。
1085
+ bad_post_type (data_type.BadPostType): 板主刪除他人文章時,對作者記的惡退(劣文)分類,
1086
+ 僅板主刪除他板友文章時有效。
1087
+ bad_post_reason (str): 惡退理由,僅 ``bad_post_type`` 為 ``BadPostType.OTHER`` 時可傳入且必須傳入,
1088
+ 長度上限 50 字。
1083
1089
 
1084
1090
  Returns:
1085
1091
  None
@@ -1090,7 +1096,12 @@ class API:
1090
1096
  NoSuchBoard: 看板不存在。
1091
1097
  NoSuchPost: 文章不存在。
1092
1098
  NoPermission: 沒有權限。
1093
- ParameterError: 對自己的文章傳入 reason(加註理由僅適用於版主刪除他人文章)。
1099
+ ParameterError: 對自己的文章傳入 reason 或 bad_post_type(僅適用於版主刪除他人文章),
1100
+ 或 bad_post_type 為 BadPostType.OTHER 卻未傳入 bad_post_reason(或超過 50 bytes,
1101
+ 或含有控制字元)。
1102
+ BadPostNotRecorded: 傳入 bad_post_type 時,文章已成功刪除,但 PTT 端沒有完成惡退
1103
+ (劣文)記錄流程(例如文章太舊而跳過惡退選單、或逾時)。代表刪除本身已經成功,
1104
+ 只是這支惡退沒有記上。
1094
1105
 
1095
1106
  範例::
1096
1107
 
@@ -1100,12 +1111,15 @@ class API:
1100
1111
  try:
1101
1112
  # .. login ..
1102
1113
  ptt_bot.del_post(board='Python', aid='1TJH_XY0')
1114
+ # 板主刪除他人文章,並記一支「廣告」惡退
1115
+ ptt_bot.del_post(board='Python', aid='1TJH_XY0',
1116
+ bad_post_type=PyPtt.BadPostType.AD)
1103
1117
  # .. do something ..
1104
1118
  finally:
1105
1119
  ptt_bot.logout()
1106
1120
  """
1107
1121
 
1108
- _api_del_post.del_post(self, board, aid, index, reason)
1122
+ _api_del_post.del_post(self, board, aid, index, reason, bad_post_type, bad_post_reason)
1109
1123
 
1110
1124
  def get_post_list(self, board: str, limit: int = 20, offset: int = 0) -> list[dict]:
1111
1125
  """
@@ -1,4 +1,4 @@
1
- __version__ = '2.1.4'
1
+ __version__ = '2.1.6'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from . import _api_util
6
+ from . import check_value
7
+ from . import command
8
+ from . import connect_core
9
+ from . import data_type
10
+ from . import exceptions
11
+ from . import i18n
12
+ from . import lib_util
13
+ from . import log
14
+ from . import screens
15
+
16
+ # pttbbs input fields cap length in bytes (Big5: 2 bytes per Chinese
17
+ # character), not in Python characters.
18
+ _BAD_POST_REASON_MAX_BYTES = 50
19
+
20
+
21
+ def _check_no_control_chars(name: str, value: str) -> None:
22
+ """Terminal command injection guard: none of PyPtt's del_post text
23
+ parameters may contain control characters (they get typed directly
24
+ into the remote terminal via connect_core.send())."""
25
+ for c in value:
26
+ if ord(c) < 0x20 or ord(c) == 0x7f:
27
+ raise exceptions.ParameterError(
28
+ f'{name} must not contain control characters (found {c!r})')
29
+
30
+
31
+ def _check_bad_post_reason(bad_post_reason: str) -> None:
32
+ _check_no_control_chars('bad_post_reason', bad_post_reason)
33
+ try:
34
+ encoded = bad_post_reason.encode('big5uao')
35
+ except UnicodeEncodeError as e:
36
+ raise exceptions.ParameterError(
37
+ f'bad_post_reason contains a character that cannot be encoded in Big5: {e}') from e
38
+ if len(encoded) > _BAD_POST_REASON_MAX_BYTES:
39
+ raise exceptions.ParameterError(
40
+ f'bad_post_reason must not exceed {_BAD_POST_REASON_MAX_BYTES} bytes')
41
+
42
+
43
+ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0, reason: Optional[str] = None,
44
+ bad_post_type: Optional[data_type.BadPostType] = None,
45
+ bad_post_reason: Optional[str] = None) -> None:
46
+ _api_util.one_thread(api)
47
+
48
+ if not api.is_registered_user:
49
+ raise exceptions.UnregisteredUser(lib_util.get_current_func_name())
50
+
51
+ if not api._is_login:
52
+ raise exceptions.RequireLogin(i18n.require_login)
53
+
54
+ check_value.check_type(board, str, 'board')
55
+ if post_aid is not None:
56
+ check_value.check_type(post_aid, str, 'PostAID')
57
+ check_value.check_type(post_index, int, 'PostIndex')
58
+ if reason is not None:
59
+ check_value.check_type(reason, str, 'reason')
60
+ if bad_post_type is not None:
61
+ check_value.check_type(bad_post_type, data_type.BadPostType, 'bad_post_type')
62
+ if bad_post_reason is not None:
63
+ check_value.check_type(bad_post_reason, str, 'bad_post_reason')
64
+
65
+ if len(board) == 0:
66
+ raise exceptions.ParameterError(f'board error parameter: {board}')
67
+
68
+ if post_index != 0 and isinstance(post_aid, str):
69
+ raise exceptions.ParameterError('wrong parameter index and aid can\'t both input')
70
+
71
+ if post_index == 0 and post_aid is None:
72
+ raise exceptions.ParameterError('wrong parameter index or aid must input')
73
+
74
+ # --- Pure parameter validation (doesn't need post_info) happens here,
75
+ # before the "already deleted" early-return below -- an invalid
76
+ # combination of parameters must always fail the same way, regardless of
77
+ # whether the post happens to already be gone. ---
78
+ if reason is not None:
79
+ _check_no_control_chars('reason', reason)
80
+
81
+ if bad_post_type == data_type.BadPostType.OTHER:
82
+ if not bad_post_reason:
83
+ raise exceptions.ParameterError(
84
+ 'bad_post_reason is required when bad_post_type is BadPostType.OTHER')
85
+ _check_bad_post_reason(bad_post_reason)
86
+ elif bad_post_reason is not None:
87
+ raise exceptions.ParameterError(
88
+ 'bad_post_reason is only valid when bad_post_type is BadPostType.OTHER')
89
+
90
+ if post_index != 0:
91
+ newest_index = api.get_newest_index(
92
+ data_type.NewIndex.BOARD,
93
+ board=board)
94
+ check_value.check_index(
95
+ 'PostIndex',
96
+ post_index,
97
+ newest_index)
98
+
99
+ log.logger.info(i18n.delete_post)
100
+
101
+ board_info = _api_util.check_board(api, board)
102
+
103
+ check_author = True
104
+ for moderator in board_info[data_type.BoardField.moderators]:
105
+ if api.ptt_id.lower() == moderator.lower():
106
+ check_author = False
107
+ break
108
+
109
+ log.logger.info(i18n.delete_post)
110
+
111
+ post_info = api.get_post(board, aid=post_aid, index=post_index, query=True)
112
+ if post_info[data_type.PostField.post_status] != data_type.PostStatus.EXISTS:
113
+ # delete success
114
+ log.logger.info(i18n.delete_post, '...', i18n.success)
115
+ return
116
+
117
+ # PTT only offers the "R加註理由" annotation when a moderator deletes
118
+ # another user's post. On your own post the reason can never be applied,
119
+ # so reject it rather than silently dropping it.
120
+ if reason and api.ptt_id.lower() == post_info[data_type.PostField.author].lower():
121
+ raise exceptions.ParameterError(
122
+ 'reason is only valid when a moderator deletes another user\'s post')
123
+
124
+ # 惡退(劣文)跟 R 加註理由一樣,只有「板主刪除他人文章」時才有意義。
125
+ if bad_post_type is not None and (
126
+ check_author or api.ptt_id.lower() == post_info[data_type.PostField.author].lower()):
127
+ raise exceptions.ParameterError(
128
+ 'bad_post_type is only valid when a moderator deletes another user\'s post')
129
+
130
+ if check_author:
131
+ if api.ptt_id.lower() != post_info[data_type.PostField.author].lower():
132
+ log.logger.info(i18n.delete_post, '...', i18n.fail)
133
+ raise exceptions.NoPermission(i18n.no_permission)
134
+
135
+ _api_util.goto_board(api, board)
136
+
137
+ cmd_list = []
138
+
139
+ if post_aid is not None:
140
+ cmd_list.append(lib_util.check_aid(post_aid))
141
+ elif post_index != 0:
142
+ cmd_list.append(str(post_index))
143
+ else:
144
+ raise exceptions.ParameterError('post_aid and post_index cannot be None at the same time')
145
+
146
+ cmd_list.append(command.enter)
147
+ cmd_list.append('d')
148
+
149
+ cmd = ''.join(cmd_list)
150
+
151
+ api.confirm = False
152
+ api.bad_post_done = False
153
+
154
+ def confirm_delete_handler(screen):
155
+ api.confirm = True
156
+
157
+ # A board moderator deleting another user's post gets an extra
158
+ # "R加註理由" option that lets us annotate the title shown after deletion.
159
+ use_reason = bool(reason) and not check_author
160
+
161
+ def confirm_delete_response(screen):
162
+ if use_reason and '加註理由' in screen:
163
+ return 'r' + command.enter
164
+ return 'y' + command.enter
165
+
166
+ target_list = [
167
+ connect_core.TargetUnit('請按任意鍵繼續', response=' '),
168
+ connect_core.TargetUnit('請確定刪除(Y/N', response=confirm_delete_response,
169
+ handler=confirm_delete_handler, max_match=1),
170
+ ]
171
+
172
+ if use_reason:
173
+ target_list.append(
174
+ connect_core.TargetUnit(
175
+ '請輸入刪除後要顯示的標題',
176
+ response=command.ctrl_e + ' ' + reason + command.enter,
177
+ max_match=1))
178
+ target_list.append(
179
+ connect_core.TargetUnit(
180
+ '請再次確定是否要用上述理由刪除(Y/N',
181
+ response='y' + command.enter,
182
+ max_match=1))
183
+
184
+ # A moderator deleting another user's post always gets asked
185
+ # "惡退文章?(y/N)" (record a bad-post strike against the author). Answering
186
+ # 'n' when bad_post_type wasn't requested is what fixes send() timing out
187
+ # for 3 seconds and returning -1 on every moderator delete.
188
+ #
189
+ # menu_shown / reason_prompted track whether PTT actually walked us
190
+ # through the bad-post flow: PTT silently skips straight past the "0.取消
191
+ # 退文" menu (e.g. for a post too old to badpost) without any error, so
192
+ # the only way to know whether a bad-post record was actually made is to
193
+ # have observed each step's screen ourselves, rather than assuming the
194
+ # scripted response was consumed.
195
+ menu_shown = False
196
+ reason_prompted = False
197
+
198
+ if not check_author:
199
+ def bad_post_menu_handler(screen):
200
+ nonlocal menu_shown
201
+ menu_shown = True
202
+
203
+ target_list.append(
204
+ connect_core.TargetUnit(
205
+ '惡退文章?(y/N',
206
+ response=('y' if bad_post_type else 'n') + command.enter,
207
+ max_match=1))
208
+
209
+ if bad_post_type is not None:
210
+ target_list.append(
211
+ connect_core.TargetUnit(
212
+ '0.取消退文',
213
+ response=str(int(bad_post_type)) + command.enter,
214
+ handler=bad_post_menu_handler,
215
+ max_match=1))
216
+
217
+ if bad_post_type == data_type.BadPostType.OTHER:
218
+ def reason_prompt_handler(screen):
219
+ nonlocal reason_prompted
220
+ reason_prompted = True
221
+
222
+ target_list.append(
223
+ connect_core.TargetUnit(
224
+ '請輸入原因',
225
+ response=bad_post_reason + command.enter,
226
+ handler=reason_prompt_handler,
227
+ max_match=1))
228
+
229
+ target_list.append(
230
+ connect_core.TargetUnit(screens.Target.InBoard, break_detect=True))
231
+
232
+ result = api.connect_core.send(cmd, target_list)
233
+
234
+ if not api.confirm:
235
+ log.logger.info(i18n.delete_post, '...', i18n.fail)
236
+ raise exceptions.NoPermission(i18n.no_permission)
237
+
238
+ # api.confirm being True means PTT already accepted the delete
239
+ # confirmation ('y' to "請確定刪除(Y/N)") -- the post itself is gone at
240
+ # this point in PTT's flow regardless of what happens afterwards (the
241
+ # bad-post menu, or simply returning to the board screen). A timeout
242
+ # (-1) after that point means send() never reached screens.Target.InBoard,
243
+ # so we can't blindly log success as before -- but we *can* say the
244
+ # delete succeeded and, if a bad-post record was requested, that it
245
+ # wasn't confirmed.
246
+ timed_out = result == -1
247
+
248
+ log.logger.info(i18n.delete_post, '...', i18n.success)
249
+
250
+ if bad_post_type is not None:
251
+ reason_ok = bad_post_type != data_type.BadPostType.OTHER or reason_prompted
252
+ api.bad_post_done = bool(menu_shown and reason_ok and not timed_out)
253
+ if not api.bad_post_done:
254
+ raise exceptions.BadPostNotRecorded(i18n.bad_post_not_recorded)
255
+ elif timed_out:
256
+ raise exceptions.UnknownError(
257
+ 'delete succeeded, but PTT never returned to the board screen '
258
+ 'afterwards (timeout)')
@@ -29,7 +29,7 @@ def get_bottom_post_list(api, board):
29
29
  bottom_length = len(bottom_screen)
30
30
 
31
31
  if bottom_length == 0:
32
- log.logger.info(i18n.catch_bottom_post_success)
32
+ log.logger.info(i18n.catch_bottom_post, '...', i18n.success)
33
33
  return list()
34
34
 
35
35
  cmd_list = []
@@ -65,6 +65,7 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
65
65
  search_list = []
66
66
  else:
67
67
  check_value.check_type(search_list, list, 'search_list')
68
+ search_list = list(search_list) # 別就地改到呼叫端傳進來的 list
68
69
 
69
70
  if (search_type, search_condition) != (None, None):
70
71
  search_list.insert(0, (search_type, search_condition))
@@ -39,6 +39,7 @@ def get_post(api, board: str, aid: Optional[str] = None, index: Optional[int] =
39
39
  search_list = []
40
40
  else:
41
41
  check_value.check_type(search_list, list, 'search_list')
42
+ search_list = list(search_list) # 別就地改到呼叫端傳進來的 list
42
43
 
43
44
  if (search_type, search_condition) != (None, None):
44
45
  search_list.insert(0, (search_type, search_condition))
@@ -121,11 +121,19 @@ def get_user(api, ptt_id: str) -> Dict:
121
121
  legal_post = int(re.findall(r'\d+', buff_1)[0])
122
122
 
123
123
  # PTT2 沒有退文
124
- if api.config.host == data_type.HOST.PTT1:
125
- int_list = re.findall(r'\d+', buff_1)
126
- illegal_post = int(int_list[1]) if len(int_list) > 1 else 0
127
- else:
124
+ if api.config.host == data_type.HOST.PTT2:
128
125
  illegal_post = None
126
+ else:
127
+ int_list = re.findall(r'\d+', buff_1)
128
+ if len(int_list) > 1:
129
+ illegal_post = int(int_list[1])
130
+ elif api.config.host == data_type.HOST.PTT1:
131
+ # Historical PTT1 behaviour: default to 0 rather than "unavailable".
132
+ illegal_post = 0
133
+ else:
134
+ # Non-PTT1 hosts (e.g. LOCALHOST) that fail to parse a second
135
+ # number: report "unavailable" rather than a fabricated 0.
136
+ illegal_post = None
129
137
 
130
138
  activity, mail = parse_user_info_from_line(activity_line)
131
139
  last_login_date, last_login_ip = parse_user_info_from_line(last_login_line)
@@ -146,6 +146,21 @@ def get_mail(api, index: int, search_type: Optional[data_type.SearchType] = None
146
146
  cmd_list.append(command.ctrl_z)
147
147
  cmd_list.append('m')
148
148
 
149
+ # search_type/search_condition 併入 search_list(比照 get_post),
150
+ # 否則單獨使用 search_type 搜尋會被忽略。
151
+ if search_list is None:
152
+ search_list = []
153
+ else:
154
+ check_value.check_type(search_list, list, 'search_list')
155
+ search_list = list(search_list) # 別就地改到呼叫端傳進來的 list
156
+
157
+ if (search_type, search_condition) != (None, None):
158
+ search_list.insert(0, (search_type, search_condition))
159
+
160
+ for search_type, search_condition in search_list:
161
+ check_value.check_type(search_type, data_type.SearchType, 'search_type')
162
+ check_value.check_type(search_condition, str, 'search_condition')
163
+
149
164
  # 處理條件整理出指令
150
165
  _cmd_list = _api_util.get_search_condition_cmd(data_type.NewIndex.MAIL, search_list)
151
166
 
@@ -9,6 +9,7 @@ import traceback
9
9
  import warnings
10
10
  from typing import Any
11
11
 
12
+ import certifi
12
13
  import websockets
13
14
  import websockets.exceptions
14
15
 
@@ -50,7 +51,9 @@ def ssl_init(verify_ssl: bool = True) -> ssl.SSLContext:
50
51
  cert_file.close()
51
52
  key_file.close()
52
53
 
53
- ctx = ssl.create_default_context()
54
+ # certifi 的 CA bundle 隨 requests 一起裝,避免 macOS 官方 python 沒根憑證時
55
+ # 出現 CERTIFICATE_VERIFY_FAILED,且不必靠使用者設 SSL_CERT_FILE。
56
+ ctx = ssl.create_default_context(cafile=certifi.where())
54
57
  ctx.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name)
55
58
  ctx.minimum_version = ssl.TLSVersion.TLSv1_2
56
59
 
@@ -1,4 +1,5 @@
1
1
  import time
2
+ from enum import IntEnum
2
3
  from enum import auto
3
4
 
4
5
  from AutoStrEnum import AutoStrEnum
@@ -162,6 +163,19 @@ class MarkType(AutoStrEnum):
162
163
  UNCONFIRMED = auto()
163
164
 
164
165
 
166
+ class BadPostType(IntEnum):
167
+ # 版主刪除他人文章時的惡退(劣文)分類,對應 PTT 選單的數字選項。
168
+
169
+ # 廣告
170
+ AD = 1
171
+ # 不當用辭
172
+ BAD_LANGUAGE = 2
173
+ # 人身攻擊
174
+ PERSONAL_ATTACK = 3
175
+ # 其他
176
+ OTHER = 4
177
+
178
+
165
179
  class FavouriteBoardField(AutoStrEnum):
166
180
  board = auto()
167
181
  type = auto()
@@ -283,3 +283,11 @@ class ParameterError(Error):
283
283
 
284
284
  def __str__(self):
285
285
  return self.message
286
+
287
+
288
+ class BadPostNotRecorded(Error):
289
+ def __init__(self, message):
290
+ self.message = message
291
+
292
+ def __str__(self):
293
+ return self.message
@@ -3,9 +3,11 @@ string_data = {
3
3
  "PTT2": "PTT2",
4
4
  "active": "Active",
5
5
  "author": "Author",
6
+ "bad_post": "bad post",
7
+ "bad_post_not_recorded": "the post was deleted, but the bad-post record was not made",
6
8
  "board": "Board",
7
9
  "can_not_use_search_post_code_f": "This status can not use the search PostField code function",
8
- "catch_bottom_post_success": "Catch bottom post success",
10
+ "catch_bottom_post": "Catch bottom post",
9
11
  "change_pw": "Change password",
10
12
  "comment": "Comment",
11
13
  "comment_content": "Comment content",
@@ -3,6 +3,8 @@ string_data = {
3
3
  "PTT2": "批踢踢兔",
4
4
  "active": "啟動",
5
5
  "author": "作者",
6
+ "bad_post": "劣文",
7
+ "bad_post_not_recorded": "文章已刪除,但退文(惡退)未成功記錄",
6
8
  "board": "看板",
7
9
  "can_not_use_search_post_code_f": "此狀態下無法使用搜尋文章代碼(AID)功能",
8
10
  "catch_bottom_post": "取得置底文章",
@@ -63,6 +63,16 @@ class Logger:
63
63
 
64
64
  def __init__(self, name: str, level: int = logging.NOTSET, logger_callback: Optional[callable] = None):
65
65
 
66
+ # Remembered independently of self.logger's own level: NOTSET (SILENT)
67
+ # is used here purely as a "fully silent" sentinel, but stdlib
68
+ # logging.Logger.isEnabledFor() treats NOTSET as "defer to the
69
+ # nearest ancestor with an explicit level" (falling back to the root
70
+ # logger's default of WARNING). That makes isEnabledFor(WARNING)
71
+ # return True even under SILENT, which previously let warning()
72
+ # leak through. Comparing against this stored level directly avoids
73
+ # relying on isEnabledFor()/root-logger defaults at all.
74
+ self._level = level
75
+
66
76
  self.logger = logging.getLogger(name)
67
77
  self.logger.setLevel(level)
68
78
 
@@ -76,9 +86,14 @@ class Logger:
76
86
  if logger_callback and callable(logger_callback):
77
87
  self.logger_callback = logger_callback
78
88
 
89
+ def _enabled_for(self, level: int) -> bool:
90
+ if self._level == logging.NOTSET:
91
+ return False
92
+ return level >= self._level
93
+
79
94
  def info(self, *args):
80
95
 
81
- if not self.logger.isEnabledFor(logging.INFO):
96
+ if not self._enabled_for(logging.INFO):
82
97
  return
83
98
 
84
99
  msg = _combine_msg(*args)
@@ -88,7 +103,7 @@ class Logger:
88
103
 
89
104
  def debug(self, *args):
90
105
 
91
- if not self.logger.isEnabledFor(logging.DEBUG):
106
+ if not self._enabled_for(logging.DEBUG):
92
107
  return
93
108
 
94
109
  msg = _combine_msg(*args)
@@ -96,6 +111,16 @@ class Logger:
96
111
  if self.logger_callback:
97
112
  self.logger_callback(msg)
98
113
 
114
+ def warning(self, *args):
115
+
116
+ if not self._enabled_for(logging.WARNING):
117
+ return
118
+
119
+ msg = _combine_msg(*args)
120
+ self.logger.warning(msg)
121
+ if self.logger_callback:
122
+ self.logger_callback(msg)
123
+
99
124
 
100
125
  logger: Optional[Logger] = None
101
126
 
@@ -0,0 +1,24 @@
1
+ key = """-----BEGIN EC PARAMETERS-----
2
+ BggqhkjOPQMBBw==
3
+ -----END EC PARAMETERS-----
4
+ -----BEGIN EC PRIVATE KEY-----
5
+ MHcCAQEEIFGaW2sWvlg2R4UP5e4CNg4hpmJ2+AH1p+ZDaE2L4ZfzoAoGCCqGSM49
6
+ AwEHoUQDQgAEMNlx7QJJjC6RbB9mPHUMmGK0uwUaTJ53DMzaFb4/DD1dhYpOvKcu
7
+ qZaNpQbvNCaBN5Zz+uJm78zyJXxXl6uTgw==
8
+ -----END EC PRIVATE KEY-----
9
+ """
10
+
11
+ cert = """-----BEGIN CERTIFICATE-----
12
+ MIIB6TCCAY8CFAKFPO04kcFNRJPUCM1H8KteAaEwMAoGCCqGSM49BAMCMHcxCzAJ
13
+ BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
+ CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
+ EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA3MTgwMjMyMjdaFw0zNjA3MTUwMjMy
16
+ MjdaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
+ eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
+ bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
+ AwEHA0IABDDZce0CSYwukWwfZjx1DJhitLsFGkyedwzM2hW+Pww9XYWKTrynLqmW
20
+ jaUG7zQmgTeWc/riZu/M8iV8V5erk4MwCgYIKoZIzj0EAwIDSAAwRQIgMOY/7UHn
21
+ waVAljYZrhuUwCQxcibRzy006NsenvmTLLwCIQDuQAAsbdtAfswclkrDHBtoCeCl
22
+ VphR8OhIzsjJE7SKjA==
23
+ -----END CERTIFICATE-----
24
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.1.4
3
+ Version: 2.1.6
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)
@@ -27,6 +27,7 @@ Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: progressbar2
29
29
  Requires-Dist: websockets>=12
30
+ Requires-Dist: certifi
30
31
  Requires-Dist: uao
31
32
  Requires-Dist: requests>=2.32
32
33
  Requires-Dist: AutoStrEnum
@@ -74,6 +74,7 @@ tests/test_give_money.py
74
74
  tests/test_i18n.py
75
75
  tests/test_init.py
76
76
  tests/test_logger.py
77
+ tests/test_login_logout.py
77
78
  tests/test_mail_send_and_del.py
78
79
  tests/test_mark_post.py
79
80
  tests/test_parse_query_post.py
@@ -1,5 +1,6 @@
1
1
  progressbar2
2
2
  websockets>=12
3
+ certifi
3
4
  uao
4
5
  requests>=2.32
5
6
  AutoStrEnum
@@ -34,6 +34,7 @@ classifiers = [
34
34
  dependencies = [
35
35
  "progressbar2",
36
36
  "websockets>=12",
37
+ "certifi",
37
38
  "uao",
38
39
  "requests>=2.32",
39
40
  "AutoStrEnum",