import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging, tempfile from datetime import datetime, timedelta from fastapi import FastAPI, Query, Body from fastapi.middleware.cors import CORSMiddleware import uvicorn from playwright.async_api import async_playwright from browser_use import Agent, Browser from langchain_ollama import ChatOllama def make_ollama(model: str | None = None, **kwargs) -> ChatOllama: """Create ChatOllama with required attrs for browser-use Agent compatibility.""" llm = ChatOllama(model=model or CLASSIFY_MODEL, **kwargs) object.__setattr__(llm, 'provider', 'ollama') object.__setattr__(llm, 'name', 'ChatOllama') object.__setattr__(llm, 'model_name', llm.model) return llm logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') logger = logging.getLogger(__name__) app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"], allow_methods=["POST"], allow_headers=["*"], ) PORT = int(os.getenv("PORT", "3008")) OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b") FX_PROFILE = os.getenv('FX_PROFILE', '') CHROME_PROFILE = os.getenv('CHROME_PROFILE', '') CHROME_LAUNCH_ARGS = [ '--headless=new', '--disable-blink-features=AutomationControlled', '--disable-background-networking', '--no-first-run', '--disable-sync', '--disable-field-trial-config', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-breakpad', '--disable-component-update', '--disable-default-apps', '--disable-dev-shm-usage', '--disable-popup-blocking', '--disable-prompt-on-repost', '--disable-renderer-backgrounding', '--metrics-recording-only', '--no-default-browser-check', '--disable-extensions-http-throttling', '--disable-hang-monitor', '--disable-ipc-flooding-protection', ] def detect_browser_from_profile(profile_path: str) -> str | None: """Detect browser type from profile path. Returns 'firefox', 'chromium', or None.""" if not profile_path: return None p = profile_path.lower().replace('\\', '/') if 'firefox' in p or '.default' in p or '.dev-edition' in p: return 'firefox' if 'chrome' in p or 'chromium' in p or 'edge' in p: return 'chromium' return None def find_firefox_profile() -> str | None: """Auto-detect Firefox profile directory cross-platform.""" import sys home = os.path.expanduser("~") candidates = [] if sys.platform == "win32": appdata = os.environ.get("APPDATA", "") if appdata: profiles_dir = os.path.join(appdata, "Mozilla", "Firefox", "Profiles") else: profiles_dir = os.path.join(home, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles") elif sys.platform == "darwin": profiles_dir = os.path.join(home, "Library", "Application Support", "Firefox", "Profiles") else: profiles_dir = os.path.join(home, ".mozilla", "firefox") if os.path.isdir(profiles_dir): for entry in os.listdir(profiles_dir): full = os.path.join(profiles_dir, entry) if os.path.isdir(full) and (".default" in entry.lower() or ".dev-edition" in entry.lower()): candidates.append(full) candidates.sort(key=lambda p: "default-release" in p.lower(), reverse=True) if candidates: logger.info("Auto-detected Firefox profile: %s", candidates[0]) return candidates[0] return None def find_chrome_profile() -> str | None: """Auto-detect Chrome/Chromium profile directory cross-platform.""" import sys home = os.path.expanduser("~") if sys.platform == "win32": base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Google", "Chrome", "User Data") elif sys.platform == "darwin": base = os.path.join(home, "Library", "Application Support", "Google", "Chrome") else: base = os.path.join(home, ".config", "google-chrome") default_profile = os.path.join(base, "Default") if os.path.isdir(default_profile): logger.info("Auto-detected Chrome profile: %s", default_profile) return default_profile return None def copy_firefox_profile(src_path: str) -> str: """Copy essential Firefox profile files to a temp dir for Playwright.""" essential = ['cookies.sqlite', 'webappsstore.sqlite', 'permissions.sqlite'] dst = tempfile.mkdtemp(prefix='fb_fx_profile_') for f_name in essential: s = os.path.join(src_path, f_name) if os.path.exists(s): shutil.copy2(s, os.path.join(dst, f_name)) with open(os.path.join(dst, 'profiles.ini'), 'w') as fh: fh.write("[Profile0]\nName=default\nIsRelative=0\nPath=.\nDefault=yes\n") logger.info("Copied Firefox profile to %s", dst) return dst def copy_chrome_profile(user_data_dir: str, profile_dir: str = 'Default') -> str: """Copy Chrome profile to temp dir for launch_persistent_context.""" essential = ['Cookies', 'Login Data', 'Bookmarks', 'Web Data'] dst = tempfile.mkdtemp(prefix='fb_chrome_udir_') src_profile = os.path.join(user_data_dir, profile_dir) os.makedirs(dst, exist_ok=True) os.makedirs(os.path.join(dst, profile_dir), exist_ok=True) dst_profile = os.path.join(dst, profile_dir) for f_name in essential: s = os.path.join(src_profile, f_name) if os.path.exists(s): shutil.copy2(s, os.path.join(dst_profile, f_name)) for sub in ['Local Storage', 'Session Storage']: s = os.path.join(src_profile, sub) if os.path.isdir(s): shutil.copytree(s, os.path.join(dst_profile, sub), dirs_exist_ok=True) local_state_src = os.path.join(user_data_dir, 'Local State') local_state_dst = os.path.join(dst, 'Local State') if os.path.exists(local_state_src): shutil.copy2(local_state_src, local_state_dst) logger.info("Copied Chrome profile from %s to %s", src_profile, dst) return dst def ensure_ublock_extension() -> str | None: """Download and extract uBlock Origin extension for ad blocking.""" ext_dir = os.path.join(tempfile.gettempdir(), 'fb_ublock_origin') manifest = os.path.join(ext_dir, 'manifest.json') if os.path.exists(manifest): return ext_dir import urllib.request, zipfile crx_url = ('https://clients2.google.com/service/update2/crx' '?response=redirect&prodversion=133&acceptformat=crx3' '&x=id%3Dddkjiahejlhfcafbddmgiahcphecmpfh%26uc') crx_path = os.path.join(tempfile.gettempdir(), 'ublock_origin.crx') try: if not os.path.exists(crx_path): logger.info("Downloading uBlock Origin extension...") urllib.request.urlretrieve(crx_url, crx_path) with zipfile.ZipFile(crx_path, 'r') as z: z.extractall(ext_dir) logger.info("uBlock Origin extension ready at %s", ext_dir) return ext_dir except Exception as e: logger.warning("Failed to load uBlock Origin extension: %s", e) return None def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]: """Parse posts from agent output or page text fallback.""" posts = [] # Try JSON from agent output json_match = re.search(r'\[.*?\]', agent_result, re.DOTALL) if json_match: try: parsed = json.loads(json_match.group()) if isinstance(parsed, list): for item in parsed: if isinstance(item, dict) and item.get('content'): posts.append({ 'content': item.get('content', ''), 'author': item.get('author', '') or item.get('publisher', '') or '', 'url': item.get('url', ''), 'date': item.get('date', ''), }) except json.JSONDecodeError: pass if not posts: lines = [l.strip() for l in agent_result.split('\n') if l.strip()] cur = [] for l in lines: if l.startswith(('1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.', '- ', '* ')): if cur: combined = ' '.join(cur) if len(combined) > 30: posts.append({'content': combined[:500], 'author': '', 'url': '', 'date': ''}) cur = [] cur.append(l) if cur: combined = ' '.join(cur) if len(combined) > 30: posts.append({'content': combined[:500], 'author': '', 'url': '', 'date': ''}) return posts BROAD_KEYWORDS = [ "website", "web design", "web develop", "web dev", "build my website", "build a website", "create a website", "need web", "looking for web", "new website", "landing page", "wordpress", "need a website", "my website", "business website", "need a designer", "help with my website", "redesign", "update my website", "looking for", "need a", "need an", "looking to", "help me build", "create my", "build me", "design my", "make me a", "would like", "need someone", "hire a", "looking to hire", "want someone", "need help with", ] OFFER_PATTERNS = [ r"\bfree\s+domain\b", r"\bweb\s+hosting\b", r"\bfree\s+website\b", r"\bprofessional\s+website", r"\baffordable\s+web\b", r"\bget\s+a\s+free\b", r"\bsee\s+more\b", r"\bbook\s+your\b", r"\bhire\s+a\s+freelancer\b", r"\bcontent\s+strategy\b", r"\bdigital\s+marketing\b", r"\bseo\s+service", r"\bsocial\s+media\b", r"\blimited\s+time\s+offer\b", r"\bspecial\s+offer\b", r"\bsign\s+up\s+now\b", r"\br\d[\d,.]", r"\bstarting\s+at\b", r"\bper\s+month\b", r"\bmonthly\b", r"\bfreelancer\s+marketplace\b", r"\bfirst\s+order\b", r"\bdomain\s+name\b", r"\bregister\s+your\s+domain\b", r"\bsponsored\b", r"\bpromoted\b", r"\badvertisement\b", ] REQUEST_PATTERNS = [ r"\bi\s+need\b", r"\bi\s+need\s+someone\b", r"\bi\s+need\s+a\b", r"\bi\s+need\s+an\b", r"\bi\s+want\b", r"\bi\s+would\s+like\b", r"\bi'm\s+looking\b", r"\bi\s+am\s+looking\b", r"\blooking\s+for\b", r"\blooking\s+to\b", r"\bwho\s+can\b", r"\bcan\s+someone\b", r"\bhelp\s+me\b", r"\bneed\s+help\b", r"\bwould\s+like\s+to\b", r"\bwant\s+to\b", r"\banyone\s+know\b", r"\bdoes\s+anyone\b", r"\brecommend\b", r"\bsuggestion\b", r"\bquote\s+(please|for|to)\b", ] def firefox_init_script() -> str: return r"""// Firefox Anti-Detection Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 }); Object.defineProperty(navigator, 'oscpu', { get: () => 'Windows NT 10.0; Win64; x64' }); Object.defineProperty(navigator, 'vendor', { get: () => '' }); Object.defineProperty(navigator, 'vendorSub', { get: () => '' }); Object.defineProperty(navigator, 'productSub', { get: () => '20100101' }); Object.defineProperty(navigator, 'product', { get: () => 'Gecko' }); Object.defineProperty(navigator, 'appCodeName', { get: () => 'Mozilla' }); Object.defineProperty(navigator, 'appName', { get: () => 'Netscape' }); Object.defineProperty(navigator, 'appVersion', { get: () => '5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' }); Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' }); Object.defineProperty(navigator, 'buildID', { get: () => '20250101000000' }); if (window.chrome !== undefined) { window.chrome = undefined; } if (navigator.permissions) { const origQ = navigator.permissions.query.bind(navigator.permissions); navigator.permissions.query = (p) => origQ(p).then(r => (['camera','microphone','geolocation','notifications'].includes(p.name) ? Object.assign(r, { state: 'prompt' }) : r)); } const _gEC = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(t, ...a) { const ctx = _gEC.call(this, t, ...a); if (ctx && (t === 'webgl' || t === 'webgl2')) { const _gP = ctx.getParameter.bind(ctx); ctx.getParameter = function(p) { if (p===37445) return 'Intel Inc.'; if (p===37446) return 'Intel Iris OpenGL Engine'; if (p===7936||p===7937||p===35724) return ''; return _gP(p); }; const _gE = ctx.getExtension.bind(ctx); ctx.getExtension = function(n) { if (n==='WEBGL_debug_renderer_info') return null; return _gE(n); }; } return ctx; }; const _tDU = HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL = function(...a) { const img = _tDU.apply(this, a); if (img.includes('image/png') && img.length > 5000) { const n = btoa(String.fromCharCode(Math.floor(Math.random()*256))); return img.slice(0,-100)+n+img.slice(-100); } return img; }; const _gFD = AnalyserNode.prototype.getFloatFrequencyData; if (_gFD) { AnalyserNode.prototype.getFloatFrequencyData = function(a) { _gFD.call(this,a); for(let i=0;i 24 }); Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }); Object.defineProperty(navigator, 'plugins', { get: () => [{name:'Widevine Content Decryption Module',filename:'widevinecdm.dll',length:1,description:'Enables Widevine licenses'},{name:'OpenH264 Video Codec',filename:'openh264.dll',length:1,description:'H.264 video codec'}] }); Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });""" def chromium_init_script() -> str: return r"""// Enhanced Chromium Anti-Detection Object.defineProperty(navigator, 'webdriver', { get: () => false }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 }); Object.defineProperty(navigator, 'oscpu', { get: () => 'Windows NT 10.0; Win64; x64' }); Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }); Object.defineProperty(navigator, 'vendorSub', { get: () => '' }); Object.defineProperty(navigator, 'productSub', { get: () => '20030107' }); Object.defineProperty(navigator, 'product', { get: () => 'Gecko' }); Object.defineProperty(navigator, 'appCodeName', { get: () => 'Mozilla' }); Object.defineProperty(navigator, 'appName', { get: () => 'Netscape' }); Object.defineProperty(navigator, 'appVersion', { get: () => '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }); Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }); window.chrome = { runtime: { onConnect: { addListener: () => {} }, onMessage: { addListener: () => {} }, sendMessage: () => {} }, app: { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, getDetails: () => null, getIsInstalled: () => false }, csi: () => {}, loadTimes: () => {} }; if (navigator.permissions) { const origQ = navigator.permissions.query.bind(navigator.permissions); navigator.permissions.query = (p) => origQ(p).then(r => (['camera','microphone','geolocation','notifications'].includes(p.name) ? Object.assign(r, { state: 'prompt' }) : r)); } const _gEC = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(t, ...a) { const ctx = _gEC.call(this, t, ...a); if (ctx && (t === 'webgl' || t === 'webgl2')) { const _gP = ctx.getParameter.bind(ctx); ctx.getParameter = function(p) { if (p===37445) return 'Intel Inc.'; if (p===37446) return 'Intel Iris OpenGL Engine'; if (p===7936||p===7937||p===35724) return ''; return _gP(p); }; const _gE = ctx.getExtension.bind(ctx); ctx.getExtension = function(n) { if (n==='WEBGL_debug_renderer_info') return null; return _gE(n); }; } return ctx; }; const _tDU = HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL = function(...a) { const img = _tDU.apply(this, a); if (img.includes('image/png') && img.length > 5000) { const n = btoa(String.fromCharCode(Math.floor(Math.random()*256))); return img.slice(0,-100)+n+img.slice(-100); } return img; }; const _gFD = AnalyserNode.prototype.getFloatFrequencyData; if (_gFD) { AnalyserNode.prototype.getFloatFrequencyData = function(a) { _gFD.call(this,a); for(let i=0;i 24 }); Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }); Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5].map(i => ({name:['Chrome PDF Plugin','Chrome PDF Viewer','Native Client','Widevine Content Decryption Module','Chromium PDF Viewer'][i-1],filename:'internal-pdf-viewer',length:1,description:['Portable Document Format','','Native Client Executable','Enables Widevine licenses',''][i-1]})) }); Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]},{type:'text/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });""" def kw_match(text: str) -> bool: t = text.lower() return any(kw in t for kw in BROAD_KEYWORDS) def is_request(text: str) -> bool: t = text.lower() return any(re.search(p, t) for p in REQUEST_PATTERNS) def is_offer(text: str) -> bool: t = text.lower() return any(re.search(p, t) for p in OFFER_PATTERNS) FB_SEARCHES = [ "looking for web developer", "need a website designed", "need website built South Africa", "need someone to build my website", "need web designer", "looking for someone to create website", "need ecommerce website built", "want to hire web developer", "need wordpress website", "I need a website for my business", "need a site for my business", "looking for website designer South Africa", "need website for my small business", "who can build me a website", "want to create a website for my business", "looking for affordable web design", "need a web developer South Africa", "help me build a website", "need someone to design my website", "looking for web designer near me", "need a website for my startup", ] VIEWPORTS = [ {'width': 1280, 'height': 800}, {'width': 1366, 'height': 768}, {'width': 1440, 'height': 900}, {'width': 1536, 'height': 864}, {'width': 1920, 'height': 1080}, ] async def get_fb_cookies(profile_path: str | None = None): cookie_db_path = profile_path or FX_PROFILE if not cookie_db_path: logger.warning("No profile path provided") return [] cookie_db = os.path.join(cookie_db_path, 'cookies.sqlite') if not os.path.exists(cookie_db): logger.warning("Cookie DB not found at %s", cookie_db) return [] tmp = os.path.join(os.path.dirname(cookie_db), f'cookies_tmp_{os.getpid()}.sqlite') for attempt in range(3): try: shutil.copy2(cookie_db, tmp) break except PermissionError: if attempt < 2: await asyncio.sleep(0.5) else: logger.error("Failed to copy cookie DB after 3 attempts") return [] try: conn = sqlite3.connect(tmp) c = conn.cursor() c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'") rows = c.fetchall() conn.close() try: os.remove(tmp) except Exception: pass return [{ "name": name, "value": value, "domain": host if host.startswith('.') else f'.{host}', "path": path, "httpOnly": True, "secure": True, "sameSite": "Lax", } for name, value, host, path in rows] except Exception as e: logger.error("Cookie DB read error: %s", e) try: os.remove(tmp) except Exception: pass return [] WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] def _parse_fb_date(block: list[str]) -> str: for l in block: l = l.strip() m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l) if m: hours = int(m.group(1)) d = datetime.now() - timedelta(hours=hours) return d.strftime('%Y-%m-%d') if l.lower() == 'yesterday': d = datetime.now() - timedelta(days=1) return d.strftime('%Y-%m-%d') low = l.lower() if low in WEEKDAY_ORDER: day_idx = WEEKDAY_ORDER.index(low) today_idx = datetime.now().weekday() days_ago = (today_idx - day_idx) % 7 d = datetime.now() - timedelta(days=days_ago) return d.strftime('%Y-%m-%d') for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']: try: dt = datetime.strptime(l, fmt) return dt.strftime('%Y-%m-%d') except ValueError: pass return datetime.now().strftime('%Y-%m-%d') def _is_within_days(date_str: str, max_days: int = 3) -> bool: """Check if date is within max_days from now. Empty/unparseable = keep.""" if not date_str: return True try: dt = datetime.strptime(date_str.strip(), '%Y-%m-%d') return (datetime.now() - dt).days <= max_days except ValueError: return True def _clean_fb_text(text: str) -> str: cleaned_lines = [] for l in text.split('\n'): stripped = l.strip() if not stripped: continue if len(stripped) < 3: continue if all(not c.isalpha() for c in stripped): continue cleaned_lines.append(stripped) return '\n'.join(cleaned_lines) def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]: posts = [] seen_texts = set() for el in elements: raw_text = el.get('text', '') if len(raw_text) < 40: continue text = _clean_fb_text(raw_text) if len(text) < 40: continue if is_offer(text): continue lines = text.split('\n') dekey = text[:80] if dekey in seen_texts: continue seen_texts.add(dekey) request_score = 2 if is_request(text) else 0 post_url = el.get('url') or base_url # Prefer JS-extracted date, fall back to text parsing js_date = (el.get('date') or '').strip() if js_date: # Try ISO datetime from