Skip to content

Commit a3fef24

Browse files
committed
Add Xquik SpiderFoot module
1 parent 0f815a2 commit a3fef24

2 files changed

Lines changed: 284 additions & 0 deletions

File tree

modules/sfp_xquik.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# -------------------------------------------------------------------------------
2+
# Name: sfp_xquik
3+
# Purpose: Query Xquik for X/Twitter profile information.
4+
#
5+
# Author: SpiderFoot contributors
6+
#
7+
# Created: 2026-06-13
8+
# Copyright: (c) SpiderFoot contributors 2026
9+
# Licence: MIT
10+
# -------------------------------------------------------------------------------
11+
12+
import json
13+
import re
14+
import urllib.parse
15+
16+
from spiderfoot import SpiderFootEvent, SpiderFootPlugin
17+
18+
19+
class sfp_xquik(SpiderFootPlugin):
20+
21+
meta = {
22+
'name': "Xquik",
23+
'summary': "Gather X/Twitter user profile details from Xquik.",
24+
'flags': ["apikey"],
25+
'useCases': ["Footprint", "Investigate", "Passive"],
26+
'categories': ["Social Media"],
27+
'dataSource': {
28+
'website': "https://xquik.com/",
29+
'model': "FREE_AUTH_LIMITED",
30+
'references': [
31+
"https://docs.xquik.com/api-reference/overview"
32+
],
33+
'apiKeyInstructions': [
34+
"Visit https://xquik.com/",
35+
"Create or sign in to your account",
36+
"Open API keys from the dashboard",
37+
"Create an API key and paste it into the module options"
38+
],
39+
'favIcon': "https://xquik.com/icon.svg",
40+
'logo': "https://xquik.com/icon.svg",
41+
'description': "Xquik provides X/Twitter data APIs for profile lookup, "
42+
"tweet search, media download, monitoring, and automation workflows.",
43+
}
44+
}
45+
46+
opts = {
47+
"api_key": "",
48+
"base_url": "https://xquik.com/api/v1"
49+
}
50+
51+
optdescs = {
52+
"api_key": "Xquik API key.",
53+
"base_url": "Xquik API base URL."
54+
}
55+
56+
handle_re = re.compile(r"^[A-Za-z0-9_]{1,15}$")
57+
58+
results = None
59+
errorState = False
60+
61+
def setup(self, sfc, userOpts=dict()):
62+
self.sf = sfc
63+
self.__dataSource__ = "Xquik"
64+
self.results = self.tempStorage()
65+
self.errorState = False
66+
67+
for opt in list(userOpts.keys()):
68+
self.opts[opt] = userOpts[opt]
69+
70+
def watchedEvents(self):
71+
return ["SOCIAL_MEDIA"]
72+
73+
def producedEvents(self):
74+
return ["RAW_RIR_DATA", "USERNAME"]
75+
76+
def extract_handle(self, event_data):
77+
try:
78+
network, value = event_data.split(": ", 1)
79+
except ValueError:
80+
return None
81+
82+
if network.lower() not in ["twitter", "x"]:
83+
return None
84+
85+
url = value.replace("<SFURL>", "").replace("</SFURL>", "").strip()
86+
87+
try:
88+
parsed = urllib.parse.urlparse(url)
89+
except Exception:
90+
return None
91+
92+
if parsed.scheme not in ["http", "https"]:
93+
return None
94+
95+
if parsed.netloc.lower() not in ["twitter.com", "www.twitter.com", "x.com", "www.x.com"]:
96+
return None
97+
98+
parts = [part for part in parsed.path.split("/") if part]
99+
if len(parts) != 1:
100+
return None
101+
102+
handle = parts[0].lstrip("@")
103+
if not self.handle_re.match(handle):
104+
return None
105+
106+
if handle.lower() in ["home", "explore", "i", "intent", "messages", "notifications", "search"]:
107+
return None
108+
109+
return handle
110+
111+
def query(self, handle):
112+
base_url = self.opts["base_url"].rstrip("/")
113+
user = urllib.parse.quote(handle, safe="")
114+
headers = {
115+
"x-api-key": self.opts["api_key"]
116+
}
117+
118+
return self.sf.fetchUrl(f"{base_url}/x/users/{user}",
119+
timeout=self.opts.get("_fetchtimeout", 30),
120+
useragent="SpiderFoot",
121+
headers=headers)
122+
123+
def handleEvent(self, event):
124+
eventData = event.data
125+
126+
if self.errorState:
127+
return
128+
129+
handle = self.extract_handle(eventData)
130+
if handle is None:
131+
return
132+
133+
lookup_key = handle.lower()
134+
if lookup_key in self.results:
135+
return
136+
137+
self.results[lookup_key] = True
138+
139+
if self.opts["api_key"] == "":
140+
self.error("You enabled sfp_xquik but did not set an API key!")
141+
self.errorState = True
142+
return
143+
144+
res = self.query(handle)
145+
if not res or not res.get("content"):
146+
return
147+
148+
code = str(res.get("code"))
149+
if code in ["401", "402", "403"]:
150+
self.error(f"Xquik API request failed with HTTP {code}.")
151+
self.errorState = True
152+
return
153+
154+
if code != "200":
155+
self.debug(f"Xquik API request failed with HTTP {code}.")
156+
return
157+
158+
try:
159+
data = json.loads(res["content"])
160+
except Exception as e:
161+
self.error(f"Error processing JSON response from Xquik: {e}")
162+
return
163+
164+
evt = SpiderFootEvent("RAW_RIR_DATA", json.dumps(data, sort_keys=True),
165+
self.__name__, event)
166+
self.notifyListeners(evt)
167+
168+
payload = data.get("data", data)
169+
if not isinstance(payload, dict):
170+
return
171+
172+
username = payload.get("username") or payload.get("screen_name") or payload.get("handle")
173+
if not username or not isinstance(username, str):
174+
return
175+
176+
username = username.lstrip("@")
177+
if not self.handle_re.match(username):
178+
return
179+
180+
evt = SpiderFootEvent("USERNAME", username, self.__name__, event)
181+
self.notifyListeners(evt)
182+
183+
# End of sfp_xquik class
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import json
2+
import pytest
3+
import unittest
4+
5+
from modules.sfp_xquik import sfp_xquik
6+
from sflib import SpiderFoot
7+
from spiderfoot import SpiderFootEvent, SpiderFootTarget
8+
9+
10+
@pytest.mark.usefixtures
11+
class TestModuleXquik(unittest.TestCase):
12+
13+
def test_opts(self):
14+
module = sfp_xquik()
15+
self.assertEqual(len(module.opts), len(module.optdescs))
16+
17+
def test_setup(self):
18+
sf = SpiderFoot(self.default_options)
19+
module = sfp_xquik()
20+
module.setup(sf, dict())
21+
22+
def test_watchedEvents_should_return_list(self):
23+
module = sfp_xquik()
24+
self.assertIsInstance(module.watchedEvents(), list)
25+
26+
def test_producedEvents_should_return_list(self):
27+
module = sfp_xquik()
28+
self.assertIsInstance(module.producedEvents(), list)
29+
30+
def test_extract_handle_should_parse_x_profile_url(self):
31+
module = sfp_xquik()
32+
result = module.extract_handle("X: <SFURL>https://x.com/xquik</SFURL>")
33+
self.assertEqual(result, "xquik")
34+
35+
def test_extract_handle_should_skip_status_url(self):
36+
module = sfp_xquik()
37+
result = module.extract_handle("Twitter: <SFURL>https://twitter.com/xquik/status/1</SFURL>")
38+
self.assertIsNone(result)
39+
40+
def test_handleEvent_no_api_key_should_set_errorState(self):
41+
sf = SpiderFoot(self.default_options)
42+
43+
module = sfp_xquik()
44+
module.setup(sf, dict())
45+
46+
target_value = 'spiderfoot.net'
47+
target_type = 'INTERNET_NAME'
48+
target = SpiderFootTarget(target_value, target_type)
49+
module.setTarget(target)
50+
51+
event_type = 'SOCIAL_MEDIA'
52+
event_data = 'Twitter: <SFURL>https://twitter.com/xquik</SFURL>'
53+
event_module = 'example module'
54+
source_event = SpiderFootEvent('ROOT', target_value, '', '')
55+
evt = SpiderFootEvent(event_type, event_data, event_module, source_event)
56+
57+
result = module.handleEvent(evt)
58+
59+
self.assertIsNone(result)
60+
self.assertTrue(module.errorState)
61+
62+
def test_handleEvent_should_emit_raw_data_and_username(self):
63+
sf = SpiderFoot(self.default_options)
64+
65+
module = sfp_xquik()
66+
module.setup(sf, {"api_key": "test-key"})
67+
68+
target_value = 'spiderfoot.net'
69+
target_type = 'INTERNET_NAME'
70+
target = SpiderFootTarget(target_value, target_type)
71+
module.setTarget(target)
72+
73+
captured = list()
74+
75+
def new_fetchUrl(url, timeout=0, useragent=None, headers=None):
76+
self.assertEqual(url, "https://xquik.com/api/v1/x/users/xquik")
77+
self.assertEqual(timeout, 30)
78+
self.assertEqual(useragent, "SpiderFoot")
79+
self.assertEqual(headers, {"x-api-key": "test-key"})
80+
return {
81+
"code": "200",
82+
"content": json.dumps({"data": {"username": "xquik", "followersCount": 42}})
83+
}
84+
85+
def new_notifyListeners(self, event):
86+
captured.append(event)
87+
88+
sf.fetchUrl = new_fetchUrl
89+
module.notifyListeners = new_notifyListeners.__get__(module, sfp_xquik)
90+
91+
event_type = 'SOCIAL_MEDIA'
92+
event_data = 'Twitter: <SFURL>https://twitter.com/xquik</SFURL>'
93+
event_module = 'example module'
94+
source_event = SpiderFootEvent('ROOT', target_value, '', '')
95+
evt = SpiderFootEvent(event_type, event_data, event_module, source_event)
96+
97+
result = module.handleEvent(evt)
98+
99+
self.assertIsNone(result)
100+
self.assertEqual([event.eventType for event in captured], ["RAW_RIR_DATA", "USERNAME"])
101+
self.assertEqual(captured[1].data, "xquik")

0 commit comments

Comments
 (0)