main(init): Initial commit.
This commit is contained in:
commit
ed929f6c3d
27 changed files with 4040 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
[._]*.s[a-v][a-z]
|
||||||
|
[._]*.sw[a-p]
|
||||||
|
[._]s[a-rt-v][a-z]
|
||||||
|
[._]ss[a-gi-z]
|
||||||
|
[._]sw[a-p]
|
||||||
|
|
||||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# socmind
|
||||||
|
|
||||||
|
The social counterpart to Gapmind.
|
||||||
48
bridges/instagram_bridge.py
Normal file
48
bridges/instagram_bridge.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
from instagrapi import Client
|
||||||
|
except ImportError:
|
||||||
|
print(json.dumps({"error": "instagrapi not installed (pip install -r requirements.txt)"}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
req = json.loads(sys.stdin.read() or "{}")
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": f"bad input: {e}"}))
|
||||||
|
return
|
||||||
|
action = req.get("action", "user")
|
||||||
|
username = (req.get("username") or "").lstrip("@")
|
||||||
|
if not username:
|
||||||
|
print(json.dumps({"error": "username required"}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cl = Client()
|
||||||
|
cl.delay_range = [1, 3]
|
||||||
|
if action == "user":
|
||||||
|
info = cl.user_info_by_username_v1(username)
|
||||||
|
print(json.dumps({
|
||||||
|
"found": True, "platform": "instagram", "username": username,
|
||||||
|
"fullName": info.full_name, "biography": info.biography,
|
||||||
|
"followers": info.follower_count, "following": info.following_count,
|
||||||
|
"posts": info.media_count, "private": info.is_private,
|
||||||
|
"verified": info.is_verified, "profileUrl": f"https://instagram.com/{username}",
|
||||||
|
}))
|
||||||
|
elif action == "posts":
|
||||||
|
uid = cl.user_id_from_username(username)
|
||||||
|
medias = cl.user_medias(uid, amount=int(req.get("count", 12) or 12))
|
||||||
|
print(json.dumps({
|
||||||
|
"username": username,
|
||||||
|
"posts": [{
|
||||||
|
"id": str(m.id), "caption": (m.caption_text or "")[:500],
|
||||||
|
"likes": m.like_count, "takenAt": str(m.taken_at),
|
||||||
|
} for m in medias],
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
print(json.dumps({"error": f"unknown action {action}"}))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"found": False, "username": username, "error": str(e)[:500]}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
bridges/requirements.txt
Normal file
3
bridges/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
instagrapi>=2.1
|
||||||
|
TikTokApi>=6.5
|
||||||
|
playwright>=1.40
|
||||||
51
bridges/tiktok_bridge.py
Normal file
51
bridges/tiktok_bridge.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
async def run(req):
|
||||||
|
try:
|
||||||
|
from TikTokApi import TikTokApi
|
||||||
|
except ImportError:
|
||||||
|
return {"error": "TikTokApi not installed (pip install -r requirements.txt)"}
|
||||||
|
username = (req.get("username") or "").lstrip("@")
|
||||||
|
if not username:
|
||||||
|
return {"error": "username required"}
|
||||||
|
action = req.get("action", "user")
|
||||||
|
try:
|
||||||
|
async with TikTokApi() as api:
|
||||||
|
await api.create_sessions(num_sessions=1, sleep_after=3)
|
||||||
|
user = api.user(username)
|
||||||
|
info = await user.info()
|
||||||
|
stats = (info.get("stats") or {}) if isinstance(info, dict) else {}
|
||||||
|
base = {
|
||||||
|
"found": True, "platform": "tiktok", "username": username,
|
||||||
|
"followers": stats.get("followerCount"),
|
||||||
|
"following": stats.get("followingCount"),
|
||||||
|
"likes": stats.get("heartCount"),
|
||||||
|
"videos": stats.get("videoCount"),
|
||||||
|
"profileUrl": f"https://tiktok.com/@{username}",
|
||||||
|
}
|
||||||
|
if action == "videos":
|
||||||
|
count = int(req.get("count", 10) or 10)
|
||||||
|
vids = []
|
||||||
|
async for v in user.videos(count=count):
|
||||||
|
d = v.as_dict if hasattr(v, "as_dict") else {}
|
||||||
|
desc = (d.get("desc") or "")[:500]
|
||||||
|
vids.append({"id": str(d.get("id")), "desc": desc})
|
||||||
|
base["videos_list"] = vids
|
||||||
|
return base
|
||||||
|
except Exception as e:
|
||||||
|
return {"found": False, "username": username, "error": str(e)[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
req = json.loads(sys.stdin.read() or "{}")
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": f"bad input: {e}"}))
|
||||||
|
return
|
||||||
|
print(json.dumps(asyncio.run(run(req))))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
18
nest-cli.json
Normal file
18
nest-cli.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"monorepo": false,
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"entryFile": "main",
|
||||||
|
"language": "ts",
|
||||||
|
"generateOptions": {
|
||||||
|
"spec": true
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "./tsconfig.build.json",
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": [],
|
||||||
|
"watchAssets": false,
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
|
}
|
||||||
2747
package-lock.json
generated
Normal file
2747
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "socmind",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Social intelligence.",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "NODE_ENV=development tsx watch src/main.ts",
|
||||||
|
"start:production": "NODE_ENV=production node dist/main.js",
|
||||||
|
"generate:bindings": "node scripts/generate-bindings.mjs",
|
||||||
|
"test": "tsx --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"social",
|
||||||
|
"osint",
|
||||||
|
"twitter",
|
||||||
|
"instagram",
|
||||||
|
"tiktok",
|
||||||
|
"youtube"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^12.0.1",
|
||||||
|
"@nestjs/core": "^12.0.1",
|
||||||
|
"@nestjs/platform-fastify": "^12.0.1",
|
||||||
|
"@nestjs/swagger": "^12.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"youtubei.js": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.2",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
250
scripts/generate-bindings.mjs
Normal file
250
scripts/generate-bindings.mjs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(__dirname, '..');
|
||||||
|
|
||||||
|
async function loadSpec(cliPath) {
|
||||||
|
if (cliPath) return JSON.parse(readFileSync(cliPath, 'utf-8'));
|
||||||
|
const res = await fetch('http://localhost:3000/api/openapi.json');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOpId(opId) {
|
||||||
|
if (!opId || typeof opId !== 'string') return 'unnamed';
|
||||||
|
return opId
|
||||||
|
.replace(/^[A-Za-z0-9]+Controller_?/, '')
|
||||||
|
.replace(/^Resolution_?/, '')
|
||||||
|
.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '') || 'unnamed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tsType(def) {
|
||||||
|
if (!def || typeof def !== 'object') return 'unknown';
|
||||||
|
if (typeof def.$ref === 'string') return sanitizeTsName(String(def.$ref.split('/').pop()));
|
||||||
|
if (def.type === 'string') return 'string';
|
||||||
|
if (def.type === 'integer' || def.type === 'number') return 'number';
|
||||||
|
if (def.type === 'boolean') return 'boolean';
|
||||||
|
if (def.type === 'array') {
|
||||||
|
const items = def.items && typeof def.items === 'object' ? def.items : { type: 'unknown' };
|
||||||
|
return tsType(items) + '[]';
|
||||||
|
}
|
||||||
|
if (Array.isArray(def.oneOf)) return def.oneOf.map(tsType).join(' | ');
|
||||||
|
if (Array.isArray(def.anyOf)) return def.anyOf.map(tsType).join(' | ');
|
||||||
|
if (def.additionalProperties && typeof def.additionalProperties === 'object')
|
||||||
|
return 'Record<string, ' + tsType(def.additionalProperties) + '>';
|
||||||
|
if (def.type === 'object' || def.properties) return 'Record<string, unknown>';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTsName(name) {
|
||||||
|
const s = String(name == null ? 'unnamed' : name).replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, '');
|
||||||
|
return s || 'Unnamed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToTemplate(path) {
|
||||||
|
return String(path).replace(/\{([^}]+)\}/g, (_, p) => '${' + sanitizeTsName(p).charAt(0).toLowerCase() + sanitizeTsName(p).slice(1) + '}');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathParamNames(path) {
|
||||||
|
const out = [];
|
||||||
|
const re = /\{([^}]+)\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(String(path)))) out.push(m[1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTypeScript(spec) {
|
||||||
|
const L = [];
|
||||||
|
L.push('// Auto-generated by scripts/generate-bindings.mjs — do not edit.');
|
||||||
|
L.push('');
|
||||||
|
const schemas = (spec.components && spec.components.schemas) || {};
|
||||||
|
for (const [name, schema] of Object.entries(schemas)) {
|
||||||
|
if (!schema || typeof schema !== 'object') continue;
|
||||||
|
L.push('export interface ' + sanitizeTsName(name) + ' {');
|
||||||
|
const props = schema.properties || {};
|
||||||
|
const required = new Set(schema.required || []);
|
||||||
|
for (const [prop, def] of Object.entries(props)) {
|
||||||
|
const opt = required.has(prop) ? '' : '?';
|
||||||
|
L.push(' ' + prop + opt + ': ' + tsType(def) + ';');
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
L.push('');
|
||||||
|
}
|
||||||
|
L.push('export interface GapmindClientConfig { baseUrl: string; token?: string; }');
|
||||||
|
L.push('');
|
||||||
|
L.push('export class GapmindClient {');
|
||||||
|
L.push(' private baseUrl: string;');
|
||||||
|
L.push(' private token?: string;');
|
||||||
|
L.push(' constructor(config: GapmindClientConfig) {');
|
||||||
|
L.push(" this.baseUrl = config.baseUrl.replace(/\\/$/, '');");
|
||||||
|
L.push(' this.token = config.token;');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' private headers(): Record<string, string> {');
|
||||||
|
L.push(" const h: Record<string, string> = { 'Content-Type': 'application/json' };");
|
||||||
|
L.push(' if (this.token) h["Authorization"] = "Bearer " + this.token;');
|
||||||
|
L.push(' return h;');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' private async request<T>(method: string, path: string, body?: unknown): Promise<T> {');
|
||||||
|
L.push(' const res = await fetch(this.baseUrl + path, { method, headers: this.headers(), body: body ? JSON.stringify(body) : undefined });');
|
||||||
|
L.push(' if (!res.ok) throw new Error("API error " + res.status + ": " + (await res.text()));');
|
||||||
|
L.push(' return res.json() as Promise<T>;');
|
||||||
|
L.push(' }');
|
||||||
|
L.push('');
|
||||||
|
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||||
|
if (!methods || typeof methods !== 'object') continue;
|
||||||
|
for (const [method, op] of Object.entries(methods)) {
|
||||||
|
if (!op || typeof op !== 'object' || !op.operationId) continue;
|
||||||
|
if (!['get','post','put','patch','delete'].includes(method)) continue;
|
||||||
|
const name = cleanOpId(op.operationId);
|
||||||
|
const http = method.toUpperCase();
|
||||||
|
const qps = ((op.parameters || []).filter((p) => p && p.in === 'query'));
|
||||||
|
const tpl = pathToTemplate(path);
|
||||||
|
const pNames = pathParamNames(path);
|
||||||
|
const params = [];
|
||||||
|
for (const pn of pNames) params.push(sanitizeTsName(pn).charAt(0).toLowerCase() + sanitizeTsName(pn).slice(1) + ': string');
|
||||||
|
for (const qp of qps) {
|
||||||
|
const qn = String(qp.name);
|
||||||
|
params.push(qn + (qp.required ? ': string' : '?: string'));
|
||||||
|
}
|
||||||
|
const hasBody = !!(op.requestBody && op.requestBody.content);
|
||||||
|
if (hasBody) params.push('body: unknown');
|
||||||
|
L.push(' async ' + name + '(' + params.join(', ') + '): Promise<unknown> {');
|
||||||
|
if (qps.length) {
|
||||||
|
L.push(' const qs = new URLSearchParams();');
|
||||||
|
for (const qp of qps) {
|
||||||
|
const qn = String(qp.name);
|
||||||
|
L.push(' if (' + qn + ' !== undefined) qs.set(' + JSON.stringify(qn) + ', String(' + qn + '));');
|
||||||
|
}
|
||||||
|
const suffix = ' + (qs.toString() ? "?" + qs.toString() : "")';
|
||||||
|
L.push(' return this.request("' + http + '", `' + tpl + '`' + suffix + (hasBody ? ', body' : '') + ');');
|
||||||
|
} else {
|
||||||
|
L.push(' return this.request("' + http + '", `' + tpl + '`' + (hasBody ? ', body' : '') + ');');
|
||||||
|
}
|
||||||
|
L.push(' }');
|
||||||
|
L.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
return L.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRustName(name) {
|
||||||
|
const parts = String(name == null ? 'unnamed' : name).split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||||
|
const s = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') || 'Unnamed';
|
||||||
|
return s.replace(/^[^A-Za-z_]/, '_');
|
||||||
|
}
|
||||||
|
function sanitizeRustField(name) {
|
||||||
|
const reserved = ['type','match','struct','enum','fn','pub','use','mod','crate','self','super','move','ref'];
|
||||||
|
let s = String(name).replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
if (!s) s = 'field';
|
||||||
|
// camelCase -> snake_case
|
||||||
|
s = s.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
|
||||||
|
if (/^[0-9]/.test(s)) s = '_' + s;
|
||||||
|
s = s.toLowerCase();
|
||||||
|
if (reserved.includes(s)) s += '_';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function rustType(def, optional) {
|
||||||
|
let inner = 'serde_json::Value';
|
||||||
|
if (def && typeof def === 'object') {
|
||||||
|
if (typeof def.$ref === 'string') inner = sanitizeRustName(String(def.$ref.split('/').pop()));
|
||||||
|
else if (def.type === 'string') inner = 'String';
|
||||||
|
else if (def.type === 'integer') inner = 'i64';
|
||||||
|
else if (def.type === 'number') inner = 'f64';
|
||||||
|
else if (def.type === 'boolean') inner = 'bool';
|
||||||
|
else if (def.type === 'array') inner = 'Vec<' + rustType(def.items || {}, false) + '>';
|
||||||
|
else if (def.type === 'object' || def.properties) inner = 'serde_json::Value';
|
||||||
|
}
|
||||||
|
return optional ? 'Option<' + inner + '>' : inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRust(spec) {
|
||||||
|
const L = [];
|
||||||
|
L.push('// Auto-generated by scripts/generate-bindings.mjs — do not edit.');
|
||||||
|
L.push('use serde::{Deserialize, Serialize};');
|
||||||
|
L.push('');
|
||||||
|
const schemas = (spec.components && spec.components.schemas) || {};
|
||||||
|
for (const [name, schema] of Object.entries(schemas)) {
|
||||||
|
if (!schema || typeof schema !== 'object') continue;
|
||||||
|
const props = schema.properties || {};
|
||||||
|
if (schema.type !== 'object' && !Object.keys(props).length) continue;
|
||||||
|
const sn = sanitizeRustName(name);
|
||||||
|
const required = new Set(schema.required || []);
|
||||||
|
L.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
|
||||||
|
L.push('pub struct ' + sn + ' {');
|
||||||
|
for (const [prop, def] of Object.entries(props)) {
|
||||||
|
const f = sanitizeRustField(prop);
|
||||||
|
if (f !== prop) L.push(' #[serde(rename = "' + prop + '")]');
|
||||||
|
if (!required.has(prop)) L.push(' #[serde(default, skip_serializing_if = "Option::is_none")]');
|
||||||
|
L.push(' pub ' + f + ': ' + rustType(def, !required.has(prop)) + ',');
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
L.push('');
|
||||||
|
}
|
||||||
|
L.push('#[derive(Debug, Clone)]');
|
||||||
|
L.push('pub struct GapmindClient { base_url: String, token: Option<String>, http: reqwest::Client }');
|
||||||
|
L.push('');
|
||||||
|
L.push('impl GapmindClient {');
|
||||||
|
L.push(' pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {');
|
||||||
|
L.push(' Self { base_url: base_url.into().trim_end_matches(\'/\').to_string(), token, http: reqwest::Client::new() }');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' fn auth(&self, b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {');
|
||||||
|
L.push(' match &self.token { Some(t) => b.bearer_auth(t), None => b }');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' pub async fn get_json(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {');
|
||||||
|
L.push(' let url = format!("{}{}", self.base_url, path);');
|
||||||
|
L.push(' let res = self.auth(self.http.get(&url)).send().await?.error_for_status()?;');
|
||||||
|
L.push(' res.json().await');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' pub async fn post_json(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value, reqwest::Error> {');
|
||||||
|
L.push(' let url = format!("{}{}", self.base_url, path);');
|
||||||
|
L.push(' let res = self.auth(self.http.post(&url).json(body)).send().await?.error_for_status()?;');
|
||||||
|
L.push(' res.json().await');
|
||||||
|
L.push(' }');
|
||||||
|
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||||
|
if (!methods || typeof methods !== 'object') continue;
|
||||||
|
for (const [method, op] of Object.entries(methods)) {
|
||||||
|
if (!op || typeof op !== 'object' || !op.operationId) continue;
|
||||||
|
if (!['get','post','put','patch','delete'].includes(method)) continue;
|
||||||
|
const fnName = sanitizeRustField(cleanOpId(op.operationId));
|
||||||
|
const pNames = pathParamNames(path);
|
||||||
|
const qps = ((op.parameters || []).filter((p) => p && p.in === 'query')).map((p) => String(p.name));
|
||||||
|
const args = [...pNames.map((p) => sanitizeRustField(p) + ': &str'), ...qps.map((q) => sanitizeRustField(q) + ': Option<&str>')];
|
||||||
|
if (op.requestBody) args.push('body: &serde_json::Value');
|
||||||
|
L.push(' pub async fn ' + fnName + '(&self' + (args.length ? ', ' + args.join(', ') : '') + ') -> Result<serde_json::Value, reqwest::Error> {');
|
||||||
|
let rsPath = String(path);
|
||||||
|
for (const p of pNames) rsPath = rsPath.split('{' + p + '}').join('{}');
|
||||||
|
// build format string with positional args
|
||||||
|
const fmtArgs = pNames.map((p) => sanitizeRustField(p)).join(', ');
|
||||||
|
L.push(' let mut url = format!("{}{}", self.base_url, format!("' + rsPath.replace(/"/g, '') + '"' + (fmtArgs ? ', ' + fmtArgs : '') + '));');
|
||||||
|
if (qps.length) {
|
||||||
|
L.push(' let mut q: Vec<(&str, String)> = Vec::new();');
|
||||||
|
for (const q of qps) {
|
||||||
|
const f = sanitizeRustField(q);
|
||||||
|
L.push(' if let Some(v) = ' + f + ' { q.push(("' + q + '", v.to_string())); }');
|
||||||
|
}
|
||||||
|
L.push(' if (!q.is_empty()) { let s: Vec<String> = q.iter().map(|(k,v)| format!("{}={}", k, v)).collect(); url.push_str(&format!("?{}", s.join("&"))); }');
|
||||||
|
}
|
||||||
|
if (method === 'get') L.push(' self.get_json(&url[self.base_url.len()..]).await');
|
||||||
|
else if (method === 'post') L.push(' self.post_json(&url[self.base_url.len()..], body).await');
|
||||||
|
else L.push(' { let res = self.auth(self.http.request(reqwest::Method::' + method.toUpperCase() + ', &url)).send().await?.error_for_status()?; res.json().await }');
|
||||||
|
L.push(' }');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
return L.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = await loadSpec(process.argv[2]);
|
||||||
|
console.log('Loaded: ' + spec.info?.title + ' v' + spec.info?.version + ' paths=' + Object.keys(spec.paths || {}).length);
|
||||||
|
mkdirSync(join(ROOT, 'clients', 'typescript'), { recursive: true });
|
||||||
|
writeFileSync(join(ROOT, 'clients', 'typescript', 'gapmind.ts'), generateTypeScript(spec));
|
||||||
|
mkdirSync(join(ROOT, 'clients', 'rust', 'src'), { recursive: true });
|
||||||
|
writeFileSync(join(ROOT, 'clients', 'rust', 'src', 'lib.rs'), generateRust(spec));
|
||||||
|
writeFileSync(join(ROOT, 'clients', 'rust', 'Cargo.toml'), '[package]\nname = "gapmind-client"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nserde = { version = "1", features = ["derive"] }\nserde_json = "1"\nreqwest = { version = "0.12", features = ["json"] }\n');
|
||||||
|
console.log('Done.');
|
||||||
24
src/app.module.ts
Normal file
24
src/app.module.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SocialController } from './social.controller';
|
||||||
|
import { TwitterService } from './platforms/twitter.service';
|
||||||
|
import { InstagramService } from './platforms/instagram.service';
|
||||||
|
import { TiktokService } from './platforms/tiktok.service';
|
||||||
|
import { FacebookSearchService } from './platforms/facebook-search.service';
|
||||||
|
import { YoutubeService } from './platforms/youtube.service';
|
||||||
|
import { RedditService, GithubService } from './platforms/misc.service';
|
||||||
|
import { PythonBridgeService } from './platforms/python-bridge.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SocialController],
|
||||||
|
providers: [
|
||||||
|
TwitterService,
|
||||||
|
InstagramService,
|
||||||
|
TiktokService,
|
||||||
|
FacebookSearchService,
|
||||||
|
YoutubeService,
|
||||||
|
RedditService,
|
||||||
|
GithubService,
|
||||||
|
PythonBridgeService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
19
src/dto.ts
Normal file
19
src/dto.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UsernameQueryDto {
|
||||||
|
@ApiProperty({ description: 'Single-word username to look up', example: 'nasa' })
|
||||||
|
username!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchQueryDto {
|
||||||
|
@ApiProperty({ description: 'Username searched across all platforms', example: 'nasa' })
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated platform filter', example: 'twitter,youtube' })
|
||||||
|
only?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TwitterTweetsQueryDto extends UsernameQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Max tweets', example: '20' })
|
||||||
|
count?: string;
|
||||||
|
}
|
||||||
40
src/main.ts
Normal file
40
src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('socmind')
|
||||||
|
.setDescription(
|
||||||
|
'Social intelligence.'
|
||||||
|
)
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addTag('Social', 'Cross-platform username lookup')
|
||||||
|
.addTag('Twitter', 'Twitter guest-API profile + tweets')
|
||||||
|
.addTag('Instagram', 'Instagram via instagrapi Python bridge')
|
||||||
|
.addTag('TikTok', 'TikTok via TikTokApi Python bridge')
|
||||||
|
.addTag('Facebook', 'Facebook via search-engine site: dorking')
|
||||||
|
.addTag('YouTube', 'YouTube via youtubei.js')
|
||||||
|
.addTag('Reddit', 'Reddit public JSON')
|
||||||
|
.addTag('GitHub', 'GitHub public API')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('/', app, document);
|
||||||
|
|
||||||
|
app.getHttpServer().on('request', (req: any, res: any) => {
|
||||||
|
if (req.url === '/api/openapi.json' && req.method === 'GET') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(document));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT || 3001);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
70
src/platforms/facebook-search.service.ts
Normal file
70
src/platforms/facebook-search.service.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FacebookSearchService {
|
||||||
|
async search(username: string) {
|
||||||
|
const word = username.replace(/^@/, '').trim();
|
||||||
|
if (!/^\S+$/.test(word) || !word)
|
||||||
|
return { found: false, reason: 'single-word username required' };
|
||||||
|
const [ddg, google] = await Promise.all([
|
||||||
|
this.ddg(word).catch(() => []),
|
||||||
|
this.google(word).catch(() => []),
|
||||||
|
]);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const results: Array<Record<string, string>> = [];
|
||||||
|
for (const r of [...ddg, ...google]) {
|
||||||
|
if (seen.has(r.url)) continue;
|
||||||
|
seen.add(r.url);
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
found: results.length > 0,
|
||||||
|
platform: 'facebook',
|
||||||
|
username: word,
|
||||||
|
results: results.slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ddg(word: string) {
|
||||||
|
const q = encodeURIComponent(`site:facebook.com "${word}"`);
|
||||||
|
const res = await fetch(`https://html.duckduckgo.com/html/?q=${q}`, {
|
||||||
|
headers: { 'User-Agent': UA },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`ddg: ${res.status}`);
|
||||||
|
return this.extractLinks(await res.text(), 'ddg');
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractLinks(html: string, source: string) {
|
||||||
|
return extractFacebookLinks(html, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async google(word: string) {
|
||||||
|
const q = encodeURIComponent(`site:facebook.com "${word}"`);
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.google.com/search?q=${q}&num=10&hl=en`,
|
||||||
|
{ headers: { 'User-Agent': UA } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`google: ${res.status}`);
|
||||||
|
return this.extractLinks(await res.text(), 'google');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure link extractor — unit tested. Shared by DDG + Google HTML. */
|
||||||
|
export function extractFacebookLinks(html: string, source: string) {
|
||||||
|
const out: Array<Record<string, string>> = [];
|
||||||
|
const re =
|
||||||
|
/https?:\/\/(?:www\.|m\.|web\.)?facebook\.com\/[A-Za-z0-9._-]+\/?/g;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(html))) {
|
||||||
|
const url = m[0].replace(/\/$/, '');
|
||||||
|
if (seen.has(url)) continue;
|
||||||
|
if (/\/(sharer|dialog|plugins|tr|login|help)/.test(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push({ url, source });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
18
src/platforms/instagram.service.ts
Normal file
18
src/platforms/instagram.service.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PythonBridgeService } from './python-bridge.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstagramService {
|
||||||
|
// Explicit @Inject: tsx/esbuild drops design:paramtypes metadata.
|
||||||
|
constructor(@Inject(PythonBridgeService) private readonly bridge: PythonBridgeService) {}
|
||||||
|
user(username: string) {
|
||||||
|
return this.bridge.run('instagram_bridge.py', { action: 'user', username });
|
||||||
|
}
|
||||||
|
posts(username: string, count = 12) {
|
||||||
|
return this.bridge.run('instagram_bridge.py', {
|
||||||
|
action: 'posts',
|
||||||
|
username,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/platforms/misc.service.ts
Normal file
57
src/platforms/misc.service.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedditService {
|
||||||
|
async user(username: string) {
|
||||||
|
const user = username.replace(/^u\//, '').replace(/^@/, '');
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.reddit.com/user/${encodeURIComponent(user)}/about.json`,
|
||||||
|
{ headers: { 'User-Agent': 'socmind/1.0' } },
|
||||||
|
);
|
||||||
|
if (res.status === 404) return { found: false, username: user };
|
||||||
|
if (!res.ok) throw new Error(`reddit: ${res.status}`);
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
const d = data?.data || {};
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
platform: 'reddit',
|
||||||
|
username: user,
|
||||||
|
karma: (d.link_karma || 0) + (d.comment_karma || 0),
|
||||||
|
createdAt: d.created_utc
|
||||||
|
? new Date(d.created_utc * 1000).toISOString()
|
||||||
|
: undefined,
|
||||||
|
profileUrl: `https://www.reddit.com/user/${user}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GithubService {
|
||||||
|
async user(username: string) {
|
||||||
|
const user = username.replace(/^@/, '');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'User-Agent': 'socmind/1.0',
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
};
|
||||||
|
if (process.env.GITHUB_TOKEN)
|
||||||
|
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.github.com/users/${encodeURIComponent(user)}`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
if (res.status === 404) return { found: false, username: user };
|
||||||
|
if (!res.ok) throw new Error(`github: ${res.status}`);
|
||||||
|
const d = (await res.json()) as any;
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
platform: 'github',
|
||||||
|
username: user,
|
||||||
|
name: d.name,
|
||||||
|
bio: d.bio,
|
||||||
|
followers: d.followers,
|
||||||
|
following: d.following,
|
||||||
|
repos: d.public_repos,
|
||||||
|
profileUrl: d.html_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/platforms/python-bridge.service.ts
Normal file
42
src/platforms/python-bridge.service.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const BRIDGES = join(__dirname, '..', '..', 'bridges');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PythonBridgeService {
|
||||||
|
run(script: string, payload: Record<string, unknown>, timeoutMs = 60000): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('python3', [join(BRIDGES, script)], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`bridge ${script} timed out`));
|
||||||
|
}, timeoutMs);
|
||||||
|
child.stdout.on('data', (d) => (out += d));
|
||||||
|
child.stderr.on('data', (d) => (err += d));
|
||||||
|
child.on('error', (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`bridge ${script} exited ${code}: ${err.slice(0, 500)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(out));
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`bridge ${script} bad JSON: ${String(e)} :: ${out.slice(0, 500)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stdin.write(JSON.stringify(payload));
|
||||||
|
child.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/platforms/tiktok.service.ts
Normal file
18
src/platforms/tiktok.service.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PythonBridgeService } from './python-bridge.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TiktokService {
|
||||||
|
// Explicit @Inject: tsx/esbuild drops design:paramtypes metadata.
|
||||||
|
constructor(@Inject(PythonBridgeService) private readonly bridge: PythonBridgeService) {}
|
||||||
|
user(username: string) {
|
||||||
|
return this.bridge.run('tiktok_bridge.py', { action: 'user', username });
|
||||||
|
}
|
||||||
|
videos(username: string, count = 10) {
|
||||||
|
return this.bridge.run('tiktok_bridge.py', {
|
||||||
|
action: 'videos',
|
||||||
|
username,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/platforms/twitter.service.ts
Normal file
110
src/platforms/twitter.service.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
const GUEST_BEARER =
|
||||||
|
'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cH5LTvJuZduEyMToxaX5OZDLgH4kA00';
|
||||||
|
const USER_BY_SCREEN_NAME =
|
||||||
|
'https://x.com/i/api/graphql/eKPrrg5u8outcxM7L3wllg/UserByScreenName';
|
||||||
|
const USER_TWEETS =
|
||||||
|
'https://x.com/i/api/graphql/vk9SKJpWNxlpxIyR32BQg/UserTweets';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TwitterService {
|
||||||
|
private guestToken?: string;
|
||||||
|
private guestAt = 0;
|
||||||
|
|
||||||
|
private async guest(): Promise<string> {
|
||||||
|
if (this.guestToken && Date.now() - this.guestAt < 30 * 60 * 1000)
|
||||||
|
return this.guestToken;
|
||||||
|
const res = await fetch('https://api.twitter.com/1.1/guest/activate.json', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${GUEST_BEARER}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`guest activate failed: ${res.status}`);
|
||||||
|
const data = (await res.json()) as { guest_token: string };
|
||||||
|
this.guestToken = data.guest_token;
|
||||||
|
this.guestAt = Date.now();
|
||||||
|
return this.guestToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(token: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${GUEST_BEARER}`,
|
||||||
|
'x-guest-token': token,
|
||||||
|
'x-twitter-active-user': 'yes',
|
||||||
|
'x-twitter-client-language': 'en',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async profile(username: string) {
|
||||||
|
const user = username.replace(/^@/, '');
|
||||||
|
const token = await this.guest();
|
||||||
|
const variables = encodeURIComponent(
|
||||||
|
JSON.stringify({ screen_name: user, withSafetyModeUserFields: true }),
|
||||||
|
);
|
||||||
|
const features = encodeURIComponent(
|
||||||
|
JSON.stringify({
|
||||||
|
hidden_profile_likes_enabled: true,
|
||||||
|
responsive_web_graphql_exclude_directive_enabled: true,
|
||||||
|
verified_phone_label_enabled: false,
|
||||||
|
subscriptions_verification_info_verified_since_enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(
|
||||||
|
`${USER_BY_SCREEN_NAME}?variables=${variables}&features=${features}`,
|
||||||
|
{ headers: this.headers(token) },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`twitter lookup failed: ${res.status}`);
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
const legacy = data?.data?.user?.result?.legacy;
|
||||||
|
if (!legacy) return { found: false, username: user };
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
platform: 'twitter',
|
||||||
|
username: user,
|
||||||
|
name: legacy.name,
|
||||||
|
description: legacy.description,
|
||||||
|
followers: legacy.followers_count,
|
||||||
|
following: legacy.friends_count,
|
||||||
|
tweets: legacy.statuses_count,
|
||||||
|
verified: Boolean(legacy.verified),
|
||||||
|
createdAt: legacy.created_at,
|
||||||
|
location: legacy.location,
|
||||||
|
url: legacy.url,
|
||||||
|
profileUrl: `https://x.com/${user}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async tweets(username: string, count = 20) {
|
||||||
|
const user = username.replace(/^@/, '');
|
||||||
|
const token = await this.guest();
|
||||||
|
const variables = encodeURIComponent(
|
||||||
|
JSON.stringify({
|
||||||
|
screen_name: user,
|
||||||
|
count: Math.min(Math.max(count, 5), 40),
|
||||||
|
includePromotedContent: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${USER_TWEETS}?variables=${variables}`, {
|
||||||
|
headers: this.headers(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`twitter timeline failed: ${res.status}`);
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
const instructions =
|
||||||
|
data?.data?.user?.result?.timeline?.timeline?.instructions || [];
|
||||||
|
const out: Array<Record<string, unknown>> = [];
|
||||||
|
for (const inst of instructions) {
|
||||||
|
for (const entry of inst.entries || []) {
|
||||||
|
const text =
|
||||||
|
entry?.content?.itemContent?.tweet_results?.result?.legacy
|
||||||
|
?.full_text;
|
||||||
|
const id =
|
||||||
|
entry?.content?.itemContent?.tweet_results?.result?.legacy?.id_str;
|
||||||
|
if (text) out.push({ id, text: String(text).slice(0, 500) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { username: user, tweets: out.slice(0, count) };
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/platforms/youtube.service.ts
Normal file
47
src/platforms/youtube.service.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Innertube } from 'youtubei.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class YoutubeService {
|
||||||
|
private client?: Innertube;
|
||||||
|
|
||||||
|
private async innertube(): Promise<Innertube> {
|
||||||
|
if (!this.client) this.client = await Innertube.create();
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(username: string) {
|
||||||
|
const yt = await this.innertube();
|
||||||
|
const results = await yt.search(username, { type: 'channel' });
|
||||||
|
const channels = (results.channels || []).slice(0, 5).map((c: any) => ({
|
||||||
|
name: c.author?.name,
|
||||||
|
id: c.author?.id,
|
||||||
|
subscribers: c.author?.subscriber_count,
|
||||||
|
url: c.author?.url,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
found: channels.length > 0,
|
||||||
|
platform: 'youtube',
|
||||||
|
username,
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async channel(username: string) {
|
||||||
|
const yt = await this.innertube();
|
||||||
|
const results = await yt.search(username, { type: 'channel' });
|
||||||
|
const first = (results.channels || [])[0] as any;
|
||||||
|
if (!first) return { found: false, username };
|
||||||
|
const channel = await yt.getChannel(first.author.id);
|
||||||
|
const header = (channel as any)?.header;
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
platform: 'youtube',
|
||||||
|
username,
|
||||||
|
name: header?.author?.name,
|
||||||
|
subscribers: header?.subscriber_count,
|
||||||
|
videos: header?.video_count,
|
||||||
|
url: first.author?.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/social.controller.ts
Normal file
163
src/social.controller.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { BadRequestException, Controller, Get, Inject, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { TwitterService } from './platforms/twitter.service';
|
||||||
|
import { InstagramService } from './platforms/instagram.service';
|
||||||
|
import { TiktokService } from './platforms/tiktok.service';
|
||||||
|
import { FacebookSearchService } from './platforms/facebook-search.service';
|
||||||
|
import { YoutubeService } from './platforms/youtube.service';
|
||||||
|
import { RedditService, GithubService } from './platforms/misc.service';
|
||||||
|
import { SearchQueryDto, TwitterTweetsQueryDto, UsernameQueryDto } from './dto';
|
||||||
|
|
||||||
|
export function singleWord(username?: string): string {
|
||||||
|
const word = (username || '').replace(/^@/, '').trim();
|
||||||
|
if (!word || /\s/.test(word))
|
||||||
|
throw new BadRequestException('Provide a single-word username.');
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Social')
|
||||||
|
@Controller()
|
||||||
|
export class SocialController {
|
||||||
|
// Explicit @Inject tokens: tsx/esbuild drops design:paramtypes metadata,
|
||||||
|
// so implicit constructor injection would resolve to undefined.
|
||||||
|
constructor(
|
||||||
|
@Inject(TwitterService) private readonly twitter: TwitterService,
|
||||||
|
@Inject(InstagramService) private readonly instagram: InstagramService,
|
||||||
|
@Inject(TiktokService) private readonly tiktok: TiktokService,
|
||||||
|
@Inject(FacebookSearchService) private readonly facebook: FacebookSearchService,
|
||||||
|
@Inject(YoutubeService) private readonly youtube: YoutubeService,
|
||||||
|
@Inject(RedditService) private readonly reddit: RedditService,
|
||||||
|
@Inject(GithubService) private readonly github: GithubService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('api/social/search')
|
||||||
|
@ApiOperation({ summary: 'Search a username across all platforms' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiQuery({ name: 'only', required: false, description: 'Comma-separated platform filter' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Per-platform results' })
|
||||||
|
async search(@Query() q: SearchQueryDto) {
|
||||||
|
const username = singleWord(q.username);
|
||||||
|
const only = new Set(
|
||||||
|
(q.only || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const want = (p: string) => !only.size || only.has(p);
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
want('twitter') ? this.twitter.profile(username) : null,
|
||||||
|
want('instagram') ? this.instagram.user(username) : null,
|
||||||
|
want('tiktok') ? this.tiktok.user(username) : null,
|
||||||
|
want('facebook') ? this.facebook.search(username) : null,
|
||||||
|
want('youtube') ? this.youtube.search(username) : null,
|
||||||
|
want('reddit') ? this.reddit.user(username) : null,
|
||||||
|
want('github') ? this.github.user(username) : null,
|
||||||
|
]);
|
||||||
|
const keys = ['twitter', 'instagram', 'tiktok', 'facebook', 'youtube', 'reddit', 'github'];
|
||||||
|
const results: Record<string, unknown> = {};
|
||||||
|
settled.forEach((s, i) => {
|
||||||
|
results[keys[i]!] =
|
||||||
|
s.status === 'fulfilled'
|
||||||
|
? (s.value ?? { skipped: true })
|
||||||
|
: { error: s.reason instanceof Error ? s.reason.message : 'failed' };
|
||||||
|
});
|
||||||
|
return { username, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/twitter')
|
||||||
|
@ApiTags('Twitter')
|
||||||
|
@ApiOperation({ summary: 'Twitter profile via guest API (twitter-scraper technique)' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Twitter profile' })
|
||||||
|
twitterProfile(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.twitter.profile(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/twitter/tweets')
|
||||||
|
@ApiTags('Twitter')
|
||||||
|
@ApiOperation({ summary: 'Recent tweets for a user' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiQuery({ name: 'count', required: false })
|
||||||
|
@ApiResponse({ status: 200, description: 'Recent tweets' })
|
||||||
|
twitterTweets(@Query() q: TwitterTweetsQueryDto) {
|
||||||
|
return this.twitter.tweets(singleWord(q.username), Number(q.count) || 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/instagram')
|
||||||
|
@ApiTags('Instagram')
|
||||||
|
@ApiOperation({ summary: 'Instagram profile via instagrapi Python bridge' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Instagram profile' })
|
||||||
|
instagramUser(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.instagram.user(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/instagram/posts')
|
||||||
|
@ApiTags('Instagram')
|
||||||
|
@ApiOperation({ summary: 'Recent Instagram posts via instagrapi bridge' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Recent posts' })
|
||||||
|
instagramPosts(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.instagram.posts(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/tiktok')
|
||||||
|
@ApiTags('TikTok')
|
||||||
|
@ApiOperation({ summary: 'TikTok profile via TikTokApi Python bridge' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'TikTok profile' })
|
||||||
|
tiktokUser(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.tiktok.user(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/tiktok/videos')
|
||||||
|
@ApiTags('TikTok')
|
||||||
|
@ApiOperation({ summary: 'Recent TikTok videos via TikTokApi bridge' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Recent videos' })
|
||||||
|
tiktokVideos(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.tiktok.videos(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/facebook')
|
||||||
|
@ApiTags('Facebook')
|
||||||
|
@ApiOperation({ summary: 'Facebook lookup via site:facebook.com search dorking' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Candidate Facebook profile URLs' })
|
||||||
|
facebookSearch(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.facebook.search(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/youtube')
|
||||||
|
@ApiTags('YouTube')
|
||||||
|
@ApiOperation({ summary: 'YouTube channel search via youtubei.js' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Matching channels' })
|
||||||
|
youtubeSearch(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.youtube.search(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/youtube/channel')
|
||||||
|
@ApiTags('YouTube')
|
||||||
|
@ApiOperation({ summary: 'YouTube channel details via youtubei.js getChannel' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Channel details' })
|
||||||
|
youtubeChannel(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.youtube.channel(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/reddit')
|
||||||
|
@ApiTags('Reddit')
|
||||||
|
@ApiOperation({ summary: 'Reddit user via public JSON' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'Reddit user' })
|
||||||
|
redditUser(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.reddit.user(singleWord(q.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('api/social/github')
|
||||||
|
@ApiTags('GitHub')
|
||||||
|
@ApiOperation({ summary: 'GitHub user via public API' })
|
||||||
|
@ApiQuery({ name: 'username', required: true })
|
||||||
|
@ApiResponse({ status: 200, description: 'GitHub user' })
|
||||||
|
githubUser(@Query() q: UsernameQueryDto) {
|
||||||
|
return this.github.user(singleWord(q.username));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
test/facebook.test.ts
Normal file
52
test/facebook.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { extractFacebookLinks } from '../src/platforms/facebook-search.service.js';
|
||||||
|
import { FacebookSearchService } from '../src/platforms/facebook-search.service.js';
|
||||||
|
|
||||||
|
describe('extractFacebookLinks', () => {
|
||||||
|
it('extracts profile URLs, dedups, skips junk paths', () => {
|
||||||
|
const html = `
|
||||||
|
<a href="https://www.facebook.com/nasa/">NASA</a>
|
||||||
|
<a href="https://www.facebook.com/nasa">dup</a>
|
||||||
|
<a href="https://facebook.com/sharer/sharer.php">share</a>
|
||||||
|
<a href="https://m.facebook.com/esa/">ESA</a>
|
||||||
|
<a href="https://example.com/nasa">other</a>`;
|
||||||
|
const out = extractFacebookLinks(html, 'ddg');
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((r) => r.url),
|
||||||
|
['https://www.facebook.com/nasa', 'https://m.facebook.com/esa'],
|
||||||
|
);
|
||||||
|
assert.ok(out.every((r) => r.source === 'ddg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for no matches', () => {
|
||||||
|
assert.deepEqual(extractFacebookLinks('<p>nothing</p>', 'google'), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FacebookSearchService.search', () => {
|
||||||
|
it('rejects multi-word usernames without fetching', async () => {
|
||||||
|
const svc = new FacebookSearchService();
|
||||||
|
const out = (await svc.search('two words')) as any;
|
||||||
|
assert.equal(out.found, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges ddg + google, dedups by url', async () => {
|
||||||
|
const svc = new FacebookSearchService();
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
(globalThis as any).fetch = async (url: string) => ({
|
||||||
|
ok: true,
|
||||||
|
text: async () =>
|
||||||
|
url.includes('duckduckgo')
|
||||||
|
? '<a href="https://facebook.com/nasa">a</a>'
|
||||||
|
: '<a href="https://facebook.com/nasa">a</a><a href="https://facebook.com/esa">b</a>',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const out = (await svc.search('nasa')) as any;
|
||||||
|
assert.equal(out.found, true);
|
||||||
|
assert.equal(out.results.length, 2);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = real;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
64
test/misc.test.ts
Normal file
64
test/misc.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { RedditService, GithubService } from '../src/platforms/misc.service.js';
|
||||||
|
|
||||||
|
function stubFetch(handler: (url: string) => any) {
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
(globalThis as any).fetch = async (url: string, _init?: any) => handler(url);
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = real;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RedditService', () => {
|
||||||
|
it('maps about.json to profile', async () => {
|
||||||
|
const restore = stubFetch(async () => ({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { link_karma: 10, comment_karma: 5, created_utc: 1600000000 },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const out = (await new RedditService().user('spez')) as any;
|
||||||
|
assert.equal(out.found, true);
|
||||||
|
assert.equal(out.karma, 15);
|
||||||
|
assert.equal(out.profileUrl, 'https://www.reddit.com/user/spez');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns found:false on 404', async () => {
|
||||||
|
const restore = stubFetch(async () => ({ status: 404, ok: false }));
|
||||||
|
try {
|
||||||
|
const out = (await new RedditService().user('nope')) as any;
|
||||||
|
assert.equal(out.found, false);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GithubService', () => {
|
||||||
|
it('maps user api to profile', async () => {
|
||||||
|
const restore = stubFetch(async () => ({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
name: 'Linus',
|
||||||
|
followers: 1,
|
||||||
|
following: 0,
|
||||||
|
public_repos: 2,
|
||||||
|
html_url: 'https://github.com/torvalds',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const out = (await new GithubService().user('torvalds')) as any;
|
||||||
|
assert.equal(out.found, true);
|
||||||
|
assert.equal(out.name, 'Linus');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
18
test/single-word.test.ts
Normal file
18
test/single-word.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { singleWord } from '../src/social.controller.js';
|
||||||
|
|
||||||
|
describe('singleWord', () => {
|
||||||
|
it('strips @ and trims', () => {
|
||||||
|
assert.equal(singleWord('@nasa '), 'nasa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects multi-word input', () => {
|
||||||
|
assert.throws(() => singleWord('two words'), /single-word/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty input', () => {
|
||||||
|
assert.throws(() => singleWord(''), /single-word/);
|
||||||
|
assert.throws(() => singleWord(undefined), /single-word/);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
test/twitter.test.ts
Normal file
100
test/twitter.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { TwitterService } from '../src/platforms/twitter.service.js';
|
||||||
|
|
||||||
|
function stubTwitter() {
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
(globalThis as any).fetch = async (url: string, _init?: any) => {
|
||||||
|
if (url.includes('guest/activate')) {
|
||||||
|
return { ok: true, json: async () => ({ guest_token: 'GUEST' }) };
|
||||||
|
}
|
||||||
|
if (url.includes('UserByScreenName')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
legacy: {
|
||||||
|
name: 'NASA',
|
||||||
|
description: 'space',
|
||||||
|
followers_count: 10,
|
||||||
|
friends_count: 1,
|
||||||
|
statuses_count: 5,
|
||||||
|
verified: false,
|
||||||
|
created_at: 'yesterday',
|
||||||
|
location: 'DC',
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.includes('UserTweets')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
legacy: { id_str: '1', full_text: 'hello world' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected url ${url}`);
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = real;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TwitterService', () => {
|
||||||
|
it('maps UserByScreenName legacy to profile', async () => {
|
||||||
|
const restore = stubTwitter();
|
||||||
|
try {
|
||||||
|
const out = (await new TwitterService().profile('nasa')) as any;
|
||||||
|
assert.equal(out.found, true);
|
||||||
|
assert.equal(out.name, 'NASA');
|
||||||
|
assert.equal(out.followers, 10);
|
||||||
|
assert.equal(out.profileUrl, 'https://x.com/nasa');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tweet texts from timeline instructions', async () => {
|
||||||
|
const restore = stubTwitter();
|
||||||
|
try {
|
||||||
|
const out = (await new TwitterService().tweets('nasa', 5)) as any;
|
||||||
|
assert.equal(out.tweets.length, 1);
|
||||||
|
assert.equal(out.tweets[0].text, 'hello world');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
8
tsconfig.build.json
Normal file
8
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.build.tsbuildinfo
Normal file
1
tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue