main(init): Initial commit.

This commit is contained in:
Hellings 2026-09-11 23:33:20 -04:00
commit ed929f6c3d
27 changed files with 4040 additions and 0 deletions

8
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,3 @@
# socmind
The social counterpart to Gapmind.

View 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
View file

@ -0,0 +1,3 @@
instagrapi>=2.1
TikTokApi>=6.5
playwright>=1.40

51
bridges/tiktok_bridge.py Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

39
package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View 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();

View 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;
}

View 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,
});
}
}

View 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,
};
}
}

View 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();
});
}
}

View 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,
});
}
}

View 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) };
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

File diff suppressed because one or more lines are too long

22
tsconfig.json Normal file
View 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"]
}
}