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