66"""
77
88import re
9+ import time as _time
910from typing import Dict , List , Optional
1011
1112import requests
1819
1920THREADS_API_BASE = "https://graph.threads.net/v1.0"
2021
22+ # Retry configuration for transient failures
23+ _MAX_RETRIES = 3
24+ _RETRY_DELAY_SECONDS = 2
25+ _REQUEST_TIMEOUT_SECONDS = 30
26+
27+
28+ class ThreadsAPIError (Exception ):
29+ """Lỗi khi gọi Threads API (token hết hạn, quyền thiếu, v.v.)."""
30+
31+ def __init__ (self , message : str , error_type : str = "" , error_code : int = 0 ):
32+ self .error_type = error_type
33+ self .error_code = error_code
34+ super ().__init__ (message )
35+
2136
2237class ThreadsClient :
2338 """Client để tương tác với Threads API (Meta)."""
@@ -26,21 +41,131 @@ def __init__(self):
2641 self .access_token = settings .config ["threads" ]["creds" ]["access_token" ]
2742 self .user_id = settings .config ["threads" ]["creds" ]["user_id" ]
2843 self .session = requests .Session ()
29- self .session .headers .update (
30- {
31- "Authorization" : f"Bearer { self .access_token } " ,
32- }
33- )
3444
3545 def _get (self , endpoint : str , params : Optional [dict ] = None ) -> dict :
36- """Make a GET request to the Threads API."""
46+ """Make a GET request to the Threads API with retry logic.
47+
48+ Raises:
49+ ThreadsAPIError: If the API returns an error in the response body.
50+ requests.HTTPError: If the HTTP request fails after retries.
51+ """
3752 url = f"{ THREADS_API_BASE } /{ endpoint } "
3853 if params is None :
3954 params = {}
4055 params ["access_token" ] = self .access_token
41- response = self .session .get (url , params = params )
42- response .raise_for_status ()
43- return response .json ()
56+
57+ last_exception : Optional [Exception ] = None
58+ for attempt in range (1 , _MAX_RETRIES + 1 ):
59+ try :
60+ response = self .session .get (url , params = params , timeout = _REQUEST_TIMEOUT_SECONDS )
61+
62+ # Check for HTTP-level errors with detailed messages
63+ if response .status_code == 401 :
64+ raise ThreadsAPIError (
65+ "Access token không hợp lệ hoặc đã hết hạn (HTTP 401). "
66+ "Vui lòng cập nhật access_token trong config.toml." ,
67+ error_type = "OAuthException" ,
68+ error_code = 401 ,
69+ )
70+ if response .status_code == 403 :
71+ raise ThreadsAPIError (
72+ "Không có quyền truy cập (HTTP 403). Kiểm tra quyền "
73+ "threads_basic_read trong Meta Developer Portal." ,
74+ error_type = "PermissionError" ,
75+ error_code = 403 ,
76+ )
77+ response .raise_for_status ()
78+ data = response .json ()
79+
80+ # Meta Graph API có thể trả về 200 nhưng body chứa error
81+ if "error" in data :
82+ err = data ["error" ]
83+ error_msg = err .get ("message" , "Unknown API error" )
84+ error_type = err .get ("type" , "" )
85+ error_code = err .get ("code" , 0 )
86+ raise ThreadsAPIError (
87+ f"Threads API error: { error_msg } (type={ error_type } , code={ error_code } )" ,
88+ error_type = error_type ,
89+ error_code = error_code ,
90+ )
91+
92+ return data
93+
94+ except (requests .ConnectionError , requests .Timeout ) as exc :
95+ last_exception = exc
96+ if attempt < _MAX_RETRIES :
97+ print_substep (
98+ f"Lỗi kết nối (lần { attempt } /{ _MAX_RETRIES } ), "
99+ f"thử lại sau { _RETRY_DELAY_SECONDS } s..." ,
100+ style = "bold yellow" ,
101+ )
102+ _time .sleep (_RETRY_DELAY_SECONDS )
103+ continue
104+ raise
105+ except (ThreadsAPIError , requests .HTTPError ):
106+ raise
107+
108+ def validate_token (self ) -> dict :
109+ """Kiểm tra access token có hợp lệ bằng cách gọi /me endpoint.
110+
111+ Returns:
112+ User profile data nếu token hợp lệ.
113+
114+ Raises:
115+ ThreadsAPIError: Nếu token không hợp lệ hoặc đã hết hạn.
116+ """
117+ try :
118+ return self ._get ("me" , params = {"fields" : "id,username" })
119+ except (ThreadsAPIError , requests .HTTPError ) as exc :
120+ raise ThreadsAPIError (
121+ "Access token không hợp lệ hoặc đã hết hạn. "
122+ "Vui lòng cập nhật access_token trong config.toml. "
123+ "Hướng dẫn: https://developers.facebook.com/docs/threads/get-started\n "
124+ f"Chi tiết: { exc } "
125+ ) from exc
126+
127+ def refresh_token (self ) -> str :
128+ """Làm mới access token (long-lived token).
129+
130+ Meta Threads API cho phép refresh long-lived tokens.
131+ Endpoint: GET /refresh_access_token?grant_type=th_refresh_token&access_token=...
132+
133+ Returns:
134+ Access token mới.
135+
136+ Raises:
137+ ThreadsAPIError: Nếu không thể refresh token.
138+ """
139+ try :
140+ url = f"{ THREADS_API_BASE } /refresh_access_token"
141+ response = self .session .get (
142+ url ,
143+ params = {
144+ "grant_type" : "th_refresh_token" ,
145+ "access_token" : self .access_token ,
146+ },
147+ timeout = _REQUEST_TIMEOUT_SECONDS ,
148+ )
149+ response .raise_for_status ()
150+ data = response .json ()
151+
152+ if "error" in data :
153+ error_msg = data ["error" ].get ("message" , "Unknown error" )
154+ raise ThreadsAPIError (f"Không thể refresh token: { error_msg } " )
155+
156+ new_token = data .get ("access_token" , "" )
157+ if not new_token :
158+ raise ThreadsAPIError ("API không trả về access_token mới khi refresh." )
159+
160+ self .access_token = new_token
161+ print_substep ("✅ Đã refresh access token thành công!" , style = "bold green" )
162+ return new_token
163+ except requests .RequestException as exc :
164+ raise ThreadsAPIError (
165+ "Không thể refresh token. Vui lòng lấy token mới từ "
166+ "Meta Developer Portal.\n "
167+ f"Chi tiết: { exc } "
168+ ) from exc
44169
45170 def get_user_threads (self , user_id : Optional [str ] = None , limit : int = 25 ) -> List [dict ]:
46171 """Lấy danh sách threads của user.
@@ -138,12 +263,51 @@ def get_threads_posts(POST_ID: str = None) -> dict:
138263
139264 Returns:
140265 Dict chứa thread content và replies.
266+
267+ Raises:
268+ ThreadsAPIError: Nếu token không hợp lệ hoặc API trả về lỗi.
269+ ValueError: Nếu không tìm thấy threads phù hợp.
141270 """
142271 print_substep ("Đang kết nối với Threads API..." )
143272
144273 client = ThreadsClient ()
145274 content = {}
146275
276+ # Bước 0: Validate token trước khi gọi API
277+ print_substep ("Đang kiểm tra access token..." )
278+ try :
279+ user_info = client .validate_token ()
280+ print_substep (
281+ f"✅ Token hợp lệ - User: @{ user_info .get ('username' , 'N/A' )} "
282+ f"(ID: { user_info .get ('id' , 'N/A' )} )" ,
283+ style = "bold green" ,
284+ )
285+ except ThreadsAPIError :
286+ # Token không hợp lệ → thử refresh
287+ print_substep (
288+ "⚠️ Token có thể đã hết hạn, đang thử refresh..." ,
289+ style = "bold yellow" ,
290+ )
291+ try :
292+ client .refresh_token ()
293+ user_info = client .validate_token ()
294+ print_substep (
295+ f"✅ Token đã refresh - User: @{ user_info .get ('username' , 'N/A' )} " ,
296+ style = "bold green" ,
297+ )
298+ except (ThreadsAPIError , requests .RequestException ) as refresh_err :
299+ print_substep (
300+ "❌ Không thể xác thực hoặc refresh token.\n "
301+ " Vui lòng lấy token mới từ Meta Developer Portal:\n "
302+ " https://developers.facebook.com/docs/threads/get-started" ,
303+ style = "bold red" ,
304+ )
305+ raise ThreadsAPIError (
306+ "Access token không hợp lệ hoặc đã hết hạn. "
307+ "Vui lòng cập nhật access_token trong config.toml. "
308+ f"Chi tiết: { refresh_err } "
309+ ) from refresh_err
310+
147311 thread_config = settings .config ["threads" ]["thread" ]
148312 max_comment_length = int (thread_config .get ("max_comment_length" , 500 ))
149313 min_comment_length = int (thread_config .get ("min_comment_length" , 1 ))
@@ -160,14 +324,36 @@ def get_threads_posts(POST_ID: str = None) -> dict:
160324 threads_list = client .get_user_threads (user_id = target_user , limit = 25 )
161325
162326 if not threads_list :
163- print_substep ("Không tìm thấy threads nào!" , style = "bold red" )
164- raise ValueError ("No threads found" )
327+ print_substep (
328+ "❌ Không tìm thấy threads nào!\n "
329+ " Kiểm tra các nguyên nhân sau:\n "
330+ f" - User ID đang dùng: { target_user } \n "
331+ " - User này có bài viết công khai không?\n "
332+ " - Token có quyền threads_basic_read?\n "
333+ " - Token có đúng cho user_id này không?" ,
334+ style = "bold red" ,
335+ )
336+ raise ValueError (
337+ f"No threads found for user '{ target_user } '. "
338+ "Verify the user has public posts and the access token has "
339+ "'threads_basic_read' permission."
340+ )
165341
166342 # Lọc theo từ khóa nếu có
167343 keywords = thread_config .get ("keywords" , "" )
344+ unfiltered_count = len (threads_list )
168345 if keywords :
169346 keyword_list = [k .strip () for k in keywords .split ("," ) if k .strip ()]
170- threads_list = client .search_threads_by_keyword (threads_list , keyword_list )
347+ filtered = client .search_threads_by_keyword (threads_list , keyword_list )
348+ if filtered :
349+ threads_list = filtered
350+ else :
351+ # Nếu keyword filter loại hết → bỏ qua filter, dùng list gốc
352+ print_substep (
353+ f"⚠️ Keyword filter ({ keywords } ) loại hết { unfiltered_count } "
354+ "threads. Bỏ qua keyword filter, dùng tất cả threads." ,
355+ style = "bold yellow" ,
356+ )
171357
172358 # Chọn thread phù hợp (chưa tạo video, đủ replies, title chưa dùng)
173359 thread = None
0 commit comments