feat: add SearX search plugin and alert backend
Add standalone !searx command for on-demand SearXNG search (top 3 results). Add SearX as a third backend (sx) to the alert plugin for keyword monitoring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ _YT_SEARCH_URL = "https://www.youtube.com/youtubei/v1/search"
|
||||
_YT_CLIENT_VERSION = "2.20250101.00.00"
|
||||
_GQL_URL = "https://gql.twitch.tv/gql"
|
||||
_GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
_SEARX_URL = "http://192.168.122.119:3000/search"
|
||||
|
||||
# -- Module-level tracking ---------------------------------------------------
|
||||
|
||||
@@ -193,11 +194,40 @@ def _search_twitch(keyword: str) -> list[dict]:
|
||||
return results
|
||||
|
||||
|
||||
# -- SearXNG search (blocking) ----------------------------------------------
|
||||
|
||||
def _search_searx(keyword: str) -> list[dict]:
|
||||
"""Search SearXNG. Blocking."""
|
||||
import urllib.parse
|
||||
|
||||
params = urllib.parse.urlencode({"q": keyword, "format": "json"})
|
||||
url = f"{_SEARX_URL}?{params}"
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||
raw = resp.read()
|
||||
resp.close()
|
||||
|
||||
data = json.loads(raw)
|
||||
results: list[dict] = []
|
||||
for item in data.get("results", []):
|
||||
item_url = item.get("url", "")
|
||||
title = item.get("title", "")
|
||||
results.append({
|
||||
"id": item_url,
|
||||
"title": title,
|
||||
"url": item_url,
|
||||
"extra": "",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# -- Backend registry -------------------------------------------------------
|
||||
|
||||
_BACKENDS: dict[str, callable] = {
|
||||
"yt": _search_youtube,
|
||||
"tw": _search_twitch,
|
||||
"sx": _search_searx,
|
||||
}
|
||||
|
||||
|
||||
|
||||
94
plugins/searx.py
Normal file
94
plugins/searx.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Plugin: SearXNG web search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
# -- Constants ---------------------------------------------------------------
|
||||
|
||||
_SEARX_URL = "http://192.168.122.119:3000/search"
|
||||
_FETCH_TIMEOUT = 10
|
||||
_MAX_RESULTS = 3
|
||||
_MAX_TITLE_LEN = 80
|
||||
_MAX_QUERY_LEN = 200
|
||||
|
||||
|
||||
# -- Pure helpers ------------------------------------------------------------
|
||||
|
||||
def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
|
||||
"""Truncate text with ellipsis if needed."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3].rstrip() + "..."
|
||||
|
||||
|
||||
# -- SearXNG search (blocking) ----------------------------------------------
|
||||
|
||||
def _search(query: str) -> list[dict]:
|
||||
"""Search SearXNG. Blocking.
|
||||
|
||||
Returns list of dicts with keys: title, url, snippet.
|
||||
Raises on HTTP or parse errors.
|
||||
"""
|
||||
params = urllib.parse.urlencode({"q": query, "format": "json"})
|
||||
url = f"{_SEARX_URL}?{params}"
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||
raw = resp.read()
|
||||
resp.close()
|
||||
|
||||
data = json.loads(raw)
|
||||
results: list[dict] = []
|
||||
for item in data.get("results", []):
|
||||
results.append({
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"snippet": item.get("content", item.get("snippet", "")),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# -- Command handler ---------------------------------------------------------
|
||||
|
||||
@command("searx", help="Search: !searx <query>")
|
||||
async def cmd_searx(bot, message):
|
||||
"""Search SearXNG and show top results.
|
||||
|
||||
Usage: !searx <query...>
|
||||
"""
|
||||
if not message.is_channel:
|
||||
await bot.reply(message, "Use this command in a channel")
|
||||
return
|
||||
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
await bot.reply(message, "Usage: !searx <query>")
|
||||
return
|
||||
|
||||
query = parts[1].strip()
|
||||
if len(query) > _MAX_QUERY_LEN:
|
||||
await bot.reply(message, f"Query too long (max {_MAX_QUERY_LEN} chars)")
|
||||
return
|
||||
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
results = await loop.run_in_executor(None, _search, query)
|
||||
except Exception as exc:
|
||||
await bot.reply(message, f"Search failed: {exc}")
|
||||
return
|
||||
|
||||
if not results:
|
||||
await bot.reply(message, f"No results for: {query}")
|
||||
return
|
||||
|
||||
for item in results[:_MAX_RESULTS]:
|
||||
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
||||
url = item["url"]
|
||||
await bot.reply(message, f"{title} -- {url}")
|
||||
Reference in New Issue
Block a user