Skip to content

Commit 5fd9e96

Browse files
committed
feat(x_integration): add count_recent_tweets method for tweet counts
Add a new cached method count_recent_tweets to fetch time-bucketed tweet counts matching a query over the last 7 days using the X v2 API. Also add corresponding LangChain tool and Pydantic schema.
1 parent aadd24a commit 5fd9e96

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

  • libs/naas-abi-marketplace/naas_abi_marketplace/applications/x/integrations

libs/naas-abi-marketplace/naas_abi_marketplace/applications/x/integrations/XIntegration.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,164 @@ def search_recent_tweets(
849849
)
850850
return envelope
851851

852+
# ------------------------------------------------------------ tweet counts
853+
854+
@cache(
855+
lambda self, query, start_time=None, end_time=None, since_id=None, until_id=None, granularity="hour", search_count_fields=None, max_pages=None: (
856+
"count_recent_tweets_"
857+
+ hashlib.md5(
858+
json_module.dumps(
859+
{
860+
"query": query,
861+
"start_time": start_time,
862+
"end_time": end_time,
863+
"since_id": since_id,
864+
"until_id": until_id,
865+
"granularity": granularity,
866+
"search_count_fields": (
867+
sorted(search_count_fields)
868+
if search_count_fields
869+
else None
870+
),
871+
"max_pages": max_pages,
872+
},
873+
sort_keys=True,
874+
default=str,
875+
).encode()
876+
).hexdigest()[:8]
877+
),
878+
cache_type=DataType.JSON,
879+
# ttl=timedelta(minutes=1),
880+
)
881+
def count_recent_tweets(
882+
self,
883+
query: str,
884+
start_time: Optional[str] = None,
885+
end_time: Optional[str] = None,
886+
since_id: Optional[str] = None,
887+
until_id: Optional[str] = None,
888+
granularity: str = "hour",
889+
search_count_fields: Optional[List[str]] = None,
890+
max_pages: Optional[int] = None,
891+
) -> Dict:
892+
"""Get the count of tweets matching a query over the last 7 days.
893+
894+
Endpoint: GET /2/tweets/counts/recent
895+
896+
Unlike ``search_recent_tweets`` this returns only time-bucketed counts
897+
(no tweet content), so it does not consume the tweet-retrieval budget and
898+
is the cheap way to size a query before searching.
899+
900+
Args:
901+
query (str): X v2 search query (1-4096 chars), e.g. "(drone OR drones
902+
OR UAS OR UAV) lang:en -is:retweet".
903+
start_time (str, optional): Oldest UTC timestamp (YYYY-MM-DDTHH:mm:ssZ),
904+
inclusive. Defaults to 7 days ago.
905+
end_time (str, optional): Newest UTC timestamp (YYYY-MM-DDTHH:mm:ssZ),
906+
exclusive. Clamped to at least 15s before "now".
907+
since_id (str, optional): Only count tweets with an ID greater than this.
908+
until_id (str, optional): Only count tweets with an ID less than this.
909+
granularity (str): Bucket size — "minute", "hour" or "day".
910+
Defaults to "hour".
911+
search_count_fields (list[str], optional): Fields to include on each
912+
count bucket (sent as ``search_count.fields``): "start", "end",
913+
"tweet_count". Defaults to all three.
914+
max_pages (int, optional): Pages of buckets to fetch (None to exhaust).
915+
Defaults to None.
916+
917+
Returns:
918+
Dict: The persisted count envelope with keys ``query``, ``options``,
919+
``results`` (with ``data`` — the merged count buckets — and ``meta``
920+
carrying the summed ``total_tweet_count``), ``total_tweet_count``,
921+
``started_at``, ``ended_at`` and ``file_path`` (object-storage path of
922+
the saved envelope JSON).
923+
"""
924+
params: Dict = {"query": query, "granularity": granularity}
925+
if start_time is not None:
926+
params["start_time"] = start_time
927+
if end_time is not None:
928+
# Same 10s rule as search/recent — clamp to a 15s safety buffer.
929+
end_time = _clamp_end_time(end_time)
930+
params["end_time"] = end_time
931+
if since_id is not None:
932+
params["since_id"] = since_id
933+
if until_id is not None:
934+
params["until_id"] = until_id
935+
if not search_count_fields:
936+
search_count_fields = ["start", "end", "tweet_count"]
937+
params["search_count.fields"] = ",".join(search_count_fields)
938+
939+
started_at = datetime.now(timezone.utc).isoformat()
940+
941+
# The counts endpoint paginates like the rest of v2 (meta.next_token /
942+
# pagination_token) but carries the running grand total in
943+
# meta.total_tweet_count rather than a mergeable list — so page it here
944+
# and sum the totals instead of reusing _get_all_items.
945+
buckets: List[Dict] = []
946+
errors: List[Dict] = []
947+
total_tweet_count = 0
948+
merged_meta: Dict = {}
949+
page = 0
950+
while True:
951+
response = self._make_request("tweets/counts/recent", params=params)
952+
buckets.extend(response.get("data") or [])
953+
errors.extend(response.get("errors") or [])
954+
page_meta = response.get("meta") or {}
955+
total_tweet_count += page_meta.get("total_tweet_count", 0)
956+
merged_meta.update(page_meta)
957+
958+
page += 1
959+
if max_pages is not None and page >= max_pages:
960+
break
961+
next_token = page_meta.get("next_token")
962+
if not next_token:
963+
break
964+
params["pagination_token"] = next_token
965+
966+
merged_meta["total_tweet_count"] = total_tweet_count
967+
results: Dict = {"data": buckets, "meta": merged_meta}
968+
if errors:
969+
results["errors"] = errors
970+
971+
ended_at = datetime.now(timezone.utc).isoformat()
972+
options = {
973+
k: v
974+
for k, v in {
975+
"start_time": start_time,
976+
"end_time": end_time,
977+
"since_id": since_id,
978+
"until_id": until_id,
979+
"granularity": granularity,
980+
"search_count_fields": search_count_fields,
981+
"max_pages": max_pages,
982+
}.items()
983+
if v is not None
984+
}
985+
envelope_dir = os.path.join(
986+
self.__configuration.datastore_path,
987+
"count_recent_tweets",
988+
slugify_query(query),
989+
)
990+
envelope_filename = (
991+
f"{datetime.now(timezone.utc).isoformat()}_{slugify_query(query)}.json"
992+
)
993+
envelope = {
994+
"query": query,
995+
"options": options,
996+
"results": results,
997+
"total_tweet_count": total_tweet_count,
998+
"started_at": started_at,
999+
"ended_at": ended_at,
1000+
"file_path": os.path.join(envelope_dir, envelope_filename),
1001+
}
1002+
self.__storage_utils.save_json(
1003+
envelope,
1004+
envelope_dir,
1005+
envelope_filename,
1006+
copy=False,
1007+
)
1008+
return envelope
1009+
8521010

8531011
def as_tools(configuration: XIntegrationConfiguration):
8541012
"""Expose the X integration as LangChain tools for agent use."""
@@ -931,6 +1089,36 @@ class SearchRecentTweetsSchema(BaseModel):
9311089
1, description="Maximum number of pages to fetch (None to exhaust)"
9321090
)
9331091

1092+
class CountRecentTweetsSchema(BaseModel):
1093+
query: str = Field(
1094+
...,
1095+
description="X v2 search query (1-4096 chars), e.g. '(drone OR drones OR UAS OR UAV) lang:en -is:retweet'",
1096+
)
1097+
start_time: Optional[str] = Field(
1098+
None,
1099+
description="Oldest UTC timestamp YYYY-MM-DDTHH:mm:ssZ (inclusive)",
1100+
)
1101+
end_time: Optional[str] = Field(
1102+
None,
1103+
description="Newest UTC timestamp YYYY-MM-DDTHH:mm:ssZ (exclusive)",
1104+
)
1105+
since_id: Optional[str] = Field(
1106+
None, description="Only count tweets with an ID greater than this"
1107+
)
1108+
until_id: Optional[str] = Field(
1109+
None, description="Only count tweets with an ID less than this"
1110+
)
1111+
granularity: str = Field(
1112+
"hour", description="Bucket size: 'minute', 'hour' or 'day'"
1113+
)
1114+
search_count_fields: Optional[List[str]] = Field(
1115+
None,
1116+
description="Count bucket fields (subset of ['start', 'end', 'tweet_count'])",
1117+
)
1118+
max_pages: Optional[int] = Field(
1119+
None, description="Maximum number of pages to fetch (None to exhaust)"
1120+
)
1121+
9341122
return [
9351123
StructuredTool(
9361124
name="x_get_user_by_id",
@@ -1041,4 +1229,21 @@ class SearchRecentTweetsSchema(BaseModel):
10411229
),
10421230
args_schema=SearchRecentTweetsSchema,
10431231
),
1232+
StructuredTool(
1233+
name="x_count_recent_tweets",
1234+
description="Count tweets posted in the last 7 days matching an X v2 query, bucketed by time — cheap way to size a query without retrieving tweet content.",
1235+
func=lambda query, start_time=None, end_time=None, since_id=None, until_id=None, granularity="hour", search_count_fields=None, max_pages=None: (
1236+
integration.count_recent_tweets(
1237+
query,
1238+
start_time=start_time,
1239+
end_time=end_time,
1240+
since_id=since_id,
1241+
until_id=until_id,
1242+
granularity=granularity,
1243+
search_count_fields=search_count_fields,
1244+
max_pages=max_pages,
1245+
)
1246+
),
1247+
args_schema=CountRecentTweetsSchema,
1248+
),
10441249
]

0 commit comments

Comments
 (0)