feat: add Threads Keyword Search API (search by keyword and topic tag)

- Add keyword_search() method to ThreadsClient with full parameter support
  (q, search_type, search_mode, media_type, since, until, limit, author_username)
- Add keyword_search constants (SEARCH_TYPE_*, SEARCH_MODE_*, SEARCH_MEDIA_*, KEYWORD_SEARCH_FIELDS)
- Add _get_keyword_search_content() helper for video pipeline integration
- Add 'keyword_search' as new content source option with fallback chain
- Add search config fields to .config.template.toml (search_query, search_type, search_mode, search_media_type)
- Update module and class docstrings

Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/c6cae05a-91f1-4ab3-abd3-22dba1f74f6d

Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com>
pull/2482/head
copilot-swe-agent[bot] 2 days ago committed by GitHub
parent fa65b3e783
commit 0d9d5f9f7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -12,6 +12,7 @@ Các API được hỗ trợ:
5. Threads Insights API Metrics cấp thread 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 < ``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 > ``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 @).
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ể
# ------------------------------------------------------------------

@ -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" }

Loading…
Cancel
Save