diff --git a/threads/threads_client.py b/threads/threads_client.py index ddc672c..0d27f65 100644 --- a/threads/threads_client.py +++ b/threads/threads_client.py @@ -12,6 +12,7 @@ Các API được hỗ trợ: 5. Threads Insights API – Metrics cấp thread và cấp user 6. Rate Limiting – Kiểm tra quota publishing 7. Token Management – Refresh long-lived token + 8. Keyword Search API – Tìm kiếm bài viết theo từ khóa hoặc thẻ chủ đề """ import re @@ -103,6 +104,36 @@ _SCORE_WEIGHT_LIKES = 5 _SCORE_WEIGHT_REPLIES = 10 _SCORE_WEIGHT_REPOSTS = 15 +# --------------------------------------------------------------------------- +# Keyword Search constants +# https://developers.facebook.com/docs/threads/keyword-search +# --------------------------------------------------------------------------- + +# search_type values +SEARCH_TYPE_TOP = "TOP" +SEARCH_TYPE_RECENT = "RECENT" + +# search_mode values +SEARCH_MODE_KEYWORD = "KEYWORD" +SEARCH_MODE_TAG = "TAG" + +# media_type values for search filter +SEARCH_MEDIA_TEXT = "TEXT" +SEARCH_MEDIA_IMAGE = "IMAGE" +SEARCH_MEDIA_VIDEO = "VIDEO" + +# Keyword search rate limit: 2,200 queries / 24 hours +_KEYWORD_SEARCH_RATE_LIMIT = 2200 + +# Fields cho Keyword Search results (owner bị loại trừ theo API docs) +KEYWORD_SEARCH_FIELDS = ( + "id,media_product_type,media_type,media_url,permalink," + "username,text,timestamp,shortcode,thumbnail_url," + "children,is_quote_status,has_replies,root_post,replied_to," + "is_reply,is_reply_owned_by_me,hide_status,reply_audience," + "link_attachment_url,alt_text" +) + # --------------------------------------------------------------------------- # Exceptions @@ -136,6 +167,7 @@ class ThreadsClient: - Insights API: metrics cấp thread & user - Rate Limiting: kiểm tra quota publishing - Token Management: validate & refresh + - Keyword Search API: tìm kiếm theo từ khóa hoặc thẻ chủ đề """ def __init__(self): @@ -904,6 +936,107 @@ class ThreadsClient: # Nếu không lấy được limit, cho phép publish (optimistic) return True + # =================================================================== + # 8. Keyword Search API + # https://developers.facebook.com/docs/threads/keyword-search + # =================================================================== + + def keyword_search( + self, + q: str, + search_type: Optional[str] = None, + search_mode: Optional[str] = None, + media_type: Optional[str] = None, + since: Optional[str] = None, + until: Optional[str] = None, + limit: Optional[int] = None, + author_username: Optional[str] = None, + fields: Optional[str] = None, + ) -> List[dict]: + """Tìm kiếm bài viết công khai trên Threads theo từ khóa hoặc thẻ chủ đề. + + Endpoint: GET /{user-id}/threads_keyword_search + Docs: https://developers.facebook.com/docs/threads/keyword-search + + Quyền cần thiết: + - threads_basic (bắt buộc) + - threads_keyword_search (bắt buộc) + + Giới hạn: 2.200 truy vấn / 24 giờ (trên mỗi user, trên tất cả apps). + Các truy vấn không trả về kết quả sẽ không được tính vào giới hạn. + + Args: + q: Từ khóa cần tìm (bắt buộc). + search_type: Loại tìm kiếm. ``TOP`` (mặc định) hoặc ``RECENT``. + search_mode: Chế độ tìm kiếm. ``KEYWORD`` (mặc định) hoặc ``TAG``. + media_type: Lọc theo loại media. ``TEXT``, ``IMAGE``, hoặc ``VIDEO``. + since: Ngày bắt đầu (Unix timestamp hoặc chuỗi strtotime). + Phải >= 1688540400 và < ``until``. + until: Ngày kết thúc (Unix timestamp hoặc chuỗi strtotime). + Phải <= thời điểm hiện tại và > ``since``. + limit: Số kết quả tối đa (mặc định 25, tối đa 100). + author_username: Lọc theo tên người dùng cụ thể (không có @). + fields: Custom fields. Mặc định dùng KEYWORD_SEARCH_FIELDS. + + Returns: + Danh sách media objects phù hợp. + + Raises: + ThreadsAPIError: Nếu API trả về lỗi (token/quyền/rate limit). + ValueError: Nếu tham số không hợp lệ. + """ + if not q or not q.strip(): + raise ValueError("Tham số 'q' (từ khóa tìm kiếm) là bắt buộc.") + + params: Dict[str, Any] = { + "q": q.strip(), + "fields": fields or KEYWORD_SEARCH_FIELDS, + } + + if search_type is not None: + if search_type not in (SEARCH_TYPE_TOP, SEARCH_TYPE_RECENT): + raise ValueError( + f"search_type không hợp lệ: '{search_type}'. " + f"Chỉ chấp nhận: {SEARCH_TYPE_TOP}, {SEARCH_TYPE_RECENT}." + ) + params["search_type"] = search_type + + if search_mode is not None: + if search_mode not in (SEARCH_MODE_KEYWORD, SEARCH_MODE_TAG): + raise ValueError( + f"search_mode không hợp lệ: '{search_mode}'. " + f"Chỉ chấp nhận: {SEARCH_MODE_KEYWORD}, {SEARCH_MODE_TAG}." + ) + params["search_mode"] = search_mode + + if media_type is not None: + if media_type not in (SEARCH_MEDIA_TEXT, SEARCH_MEDIA_IMAGE, SEARCH_MEDIA_VIDEO): + raise ValueError( + f"media_type không hợp lệ: '{media_type}'. " + f"Chỉ chấp nhận: {SEARCH_MEDIA_TEXT}, {SEARCH_MEDIA_IMAGE}, {SEARCH_MEDIA_VIDEO}." + ) + params["media_type"] = media_type + + if since is not None: + params["since"] = since + + if until is not None: + params["until"] = until + + if limit is not None: + if limit < 0 or limit > 100: + raise ValueError( + f"limit không hợp lệ: {limit}. Phải trong khoảng 0-100." + ) + params["limit"] = limit + + if author_username is not None: + # Loại bỏ @ nếu user vô tình thêm vào + params["author_username"] = author_username.lstrip("@") + + data = self._get(f"{self.user_id}/threads_keyword_search", params=params) + return data.get("data", []) + # =================================================================== # Utility methods (không gọi API) # =================================================================== @@ -1197,6 +1330,195 @@ def _get_google_trends_content( return content +def _get_keyword_search_content( + max_comment_length: int, + min_comment_length: int, +) -> Optional[dict]: + """Lấy nội dung từ Threads bằng Keyword Search API. + + Sử dụng Threads Keyword Search API chính thức để tìm bài viết + công khai theo từ khóa hoặc thẻ chủ đề. + Trả về None nếu không thể lấy content (để fallback sang source khác). + + Yêu cầu quyền: threads_basic + threads_keyword_search. + """ + thread_config = settings.config["threads"]["thread"] + search_query = thread_config.get("search_query", "") + if not search_query: + print_substep( + "⚠️ Chưa cấu hình search_query cho keyword_search.", + style="bold yellow", + ) + return None + + search_type = thread_config.get("search_type", SEARCH_TYPE_TOP) + search_mode = thread_config.get("search_mode", SEARCH_MODE_KEYWORD) + search_media_type = thread_config.get("search_media_type", "") + + client = ThreadsClient() + + try: + print_substep( + f"🔎 Đang tìm kiếm trên Threads: '{search_query}' " + f"(mode={search_mode}, type={search_type})...", + style="bold blue", + ) + + search_kwargs: Dict[str, Any] = { + "q": search_query, + "search_type": search_type, + "search_mode": search_mode, + "limit": 25, + } + if search_media_type: + search_kwargs["media_type"] = search_media_type + + results = client.keyword_search(**search_kwargs) + except ThreadsAPIError as e: + print_substep( + f"⚠️ Lỗi Keyword Search API: {e}", + style="bold yellow", + ) + return None + except requests.RequestException as e: + print_substep( + f"⚠️ Lỗi kết nối khi tìm kiếm: {e}", + style="bold yellow", + ) + return None + + if not results: + print_substep( + f"⚠️ Không tìm thấy kết quả cho '{search_query}'.", + style="bold yellow", + ) + return None + + print_substep( + f"✅ Tìm thấy {len(results)} bài viết từ Keyword Search.", + style="bold green", + ) + + # Chọn thread phù hợp (chưa tạo video, không chứa từ bị chặn) + thread = None + for t in results: + text = t.get("text", "") + if not text or _contains_blocked_words(text): + continue + if t.get("is_reply"): + continue + title_candidate = text[:_MAX_TITLE_LENGTH] + if is_title_used(title_candidate): + print_substep( + f"Bỏ qua thread đã tạo video: {text[:50]}...", + style="bold yellow", + ) + continue + thread = t + break + + if thread is None: + if results: + thread = results[0] + else: + return None + + thread_id = thread.get("id", "") + thread_text = thread.get("text", "") + thread_username = thread.get("username", "unknown") + thread_url = thread.get( + "permalink", + f"https://www.threads.net/post/{thread.get('shortcode', '')}", + ) + shortcode = thread.get("shortcode", "") + + # Dùng search_query làm tiêu đề video + display_title = ( + f"{search_query}: {thread_text[:_MAX_TITLE_LENGTH - len(search_query) - 2]}" + if search_query + else thread_text[:_MAX_TITLE_LENGTH] + ) + + print_substep( + f"Video sẽ được tạo từ Keyword Search: {display_title[:100]}...", + style="bold green", + ) + print_substep(f"Thread URL: {thread_url}", style="bold green") + print_substep(f"Tác giả: @{thread_username}", style="bold blue") + print_substep( + f"Từ khóa tìm kiếm: {search_query} (mode={search_mode})", + style="bold blue", + ) + + content: dict = { + "thread_url": thread_url, + "thread_title": display_title, + "thread_id": re.sub( + r"[^\w\s-]", "", + shortcode or thread_id or f"kwsearch_{hash(thread_text) % 10**8}", + ), + "thread_author": f"@{thread_username}", + "is_nsfw": False, + "thread_post": thread_text, + "comments": [], + } + + if not settings.config["settings"].get("storymode", False): + # Lấy replies qua Conversation API (nếu có thread_id) + if thread_id: + try: + conversation = client.get_conversation(thread_id, limit=100) + except (ThreadsAPIError, requests.RequestException): + try: + conversation = client.get_thread_replies(thread_id, limit=50) + except Exception as e: + print_substep( + f"⚠️ Lỗi lấy replies (Keyword Search): {e}", + style="bold yellow", + ) + conversation = [] + else: + conversation = [] + + for reply in conversation: + reply_text = reply.get("text", "") + reply_username = reply.get("username", "unknown") + + if not reply_text: + continue + if reply.get("hide_status", "") == "HIDDEN": + continue + if _contains_blocked_words(reply_text): + continue + + sanitised = sanitize_text(reply_text) + if not sanitised or sanitised.strip() == "": + continue + + if len(reply_text) > max_comment_length: + continue + if len(reply_text) < min_comment_length: + continue + + content["comments"].append( + { + "comment_body": reply_text, + "comment_url": reply.get("permalink", ""), + "comment_id": re.sub( + r"[^\w\s-]", "", reply.get("id", "") + ), + "comment_author": f"@{reply_username}", + } + ) + + print_substep( + f"Đã lấy nội dung từ Keyword Search thành công! " + f"({len(content.get('comments', []))} replies)", + style="bold green", + ) + return content + + def _select_best_thread( client: ThreadsClient, threads_list: List[dict], @@ -1390,6 +1712,32 @@ def get_threads_posts(POST_ID: str = None) -> dict: style="bold yellow", ) + # ------------------------------------------------------------------ + # Source: keyword_search – Tìm kiếm bằng Keyword Search API + # ------------------------------------------------------------------ + if source == "keyword_search" and not POST_ID: + content = _get_keyword_search_content( + max_comment_length=max_comment_length, + min_comment_length=min_comment_length, + ) + if content is not None: + return content + # Fallback: keyword_search thất bại → thử trending → user threads + print_substep( + "⚠️ Keyword Search không khả dụng, thử lấy từ Trending...", + style="bold yellow", + ) + content = _get_trending_content( + max_comment_length=max_comment_length, + min_comment_length=min_comment_length, + ) + if content is not None: + return content + print_substep( + "⚠️ Trending cũng không khả dụng, chuyển sang user threads...", + style="bold yellow", + ) + # ------------------------------------------------------------------ # Source: user (mặc định) hoặc POST_ID cụ thể # ------------------------------------------------------------------ diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 3e80f59..08f4831 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -5,7 +5,7 @@ access_token = { optional = false, nmin = 10, explanation = "Threads API access user_id = { optional = false, nmin = 1, explanation = "Threads user ID của bạn", example = "12345678" } [threads.thread] -source = { optional = true, default = "trending", options = ["user", "trending", "google_trends"], explanation = "Nguồn lấy bài viết: 'trending' (từ Trending now - mặc định), 'google_trends' (từ khóa Google Trends), hoặc 'user' (từ user cụ thể)", example = "trending" } +source = { optional = true, default = "trending", options = ["user", "trending", "google_trends", "keyword_search"], explanation = "Nguồn lấy bài viết: 'trending' (từ Trending now - mặc định), 'google_trends' (từ khóa Google Trends), 'keyword_search' (Keyword Search API), hoặc 'user' (từ user cụ thể)", example = "trending" } target_user_id = { optional = true, default = "", explanation = "ID user muốn lấy threads. Để trống dùng user của bạn. Chỉ dùng khi source = 'user'.", example = "87654321" } post_id = { optional = true, default = "", explanation = "ID cụ thể của thread. Để trống để tự động chọn.", example = "18050000000000000" } keywords = { optional = true, default = "", type = "str", explanation = "Từ khóa lọc threads, phân cách bằng dấu phẩy.", example = "viral, trending, hài hước" } @@ -17,6 +17,10 @@ blocked_words = { optional = true, default = "", type = "str", explanation = "T channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN Stories", explanation = "Tên kênh hiển thị trên video" } use_conversation = { optional = true, default = true, type = "bool", options = [true, false], explanation = "Dùng Conversation API (lấy full reply tree) thay vì chỉ direct replies. Mặc định: true" } use_insights = { optional = true, default = true, type = "bool", options = [true, false], explanation = "Dùng Insights API để chọn thread có engagement cao nhất. Mặc định: true" } +search_query = { optional = true, default = "", type = "str", explanation = "Từ khóa tìm kiếm khi source = 'keyword_search'. Yêu cầu quyền threads_keyword_search.", example = "viral trending" } +search_type = { optional = true, default = "TOP", options = ["TOP", "RECENT"], explanation = "Loại kết quả tìm kiếm: TOP (phổ biến nhất) hoặc RECENT (mới nhất). Mặc định: TOP" } +search_mode = { optional = true, default = "KEYWORD", options = ["KEYWORD", "TAG"], explanation = "Chế độ tìm kiếm: KEYWORD (từ khóa) hoặc TAG (thẻ chủ đề). Mặc định: KEYWORD" } +search_media_type = { optional = true, default = "", options = ["", "TEXT", "IMAGE", "VIDEO"], explanation = "Lọc loại media khi tìm kiếm: TEXT, IMAGE, VIDEO. Để trống để không lọc." } [threads.publishing] enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật Publishing API - đăng bài mới lên Threads sau khi tạo video" }