# ── Imports ────────────────────────────────────────────────────────── 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 # ── Lazy Imports for browser-use (not always available) ────────────── def _make_ollama(model: str | None = None, **kwargs): from langchain_ollama import ChatOllama 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 def _make_browser(**kwargs): from browser_use import Browser return Browser(**kwargs) def _make_agent(task: str, llm, browser, **kwargs): from browser_use import Agent return Agent(task=task, llm=llm, browser=browser, **kwargs) # ── Logging & App Setup ────────────────────────────────────────────── 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")) # ── AI / Ollama Config ─────────────────────────────────────────────── OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b") # ── Env Vars (set via .env.local) ────────────────────────────────────── # These store detected browser profile paths from the setup wizard. FX_PROFILE = os.getenv('FX_PROFILE', '') CHROME_PROFILE = os.getenv('CHROME_PROFILE', '') OPERA_PROFILE = os.getenv('OPERA_PROFILE', '') EDGE_PROFILE = os.getenv('EDGE_PROFILE', '') # SELECTED_BROWSER determines which browser the scrape dispatcher tries first. # Values: "firefox", "chrome", "opera", "edge", or "" (auto-detect all). SELECTED_BROWSER = os.getenv('SELECTED_BROWSER', '') # ── Chromium Launch Args ────────────────────────────────────────────── # These flags reduce detectable automation signals in Chrome/Edge/Opera. # They disable background services, sync, updates, throttling, popups, etc. 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', ] # ── Profile Detection ──────────────────────────────────────────────── # All find_*_profile() functions return the parent directory (User Data or equivalent), # NOT the "Default" subdirectory. This is important because copy_chrome_profile() # appends "Default" internally, so returning the full Default path would cause # duplicated path segments (e.g. ".../User Data/Default/Default"). def detect_browser_from_profile(profile_path: str) -> str | None: """ Infer browser type from the profile path string. Checks for keywords like "firefox", "opera", "edge", "chrome" in the path. Returns "firefox", "chrome", "opera", "edge", 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 'opera' in p: return 'opera' if 'edge' in p: return 'edge' if 'chrome' in p or 'chromium' in p: return 'chrome' return None def find_firefox_profile() -> str | None: """Auto-detect Firefox profile directory cross-platform. Scans the Profiles dir and returns the first .default or .dev-edition folder found, preferring "default-release" profiles.""" 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 profile (User Data dir) cross-platform. Returns the User Data parent dir (NOT the Default subdirectory).""" 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") if os.path.isdir(os.path.join(base, "Default")): logger.info("Auto-detected Chrome profile: %s", base) return base return None def find_opera_profile() -> str | None: """Auto-detect Opera profile directory cross-platform. Opera stores its profile at the "Opera Stable" level (no "User Data/Default" nesting like Chrome/Edge), so this returns the profile root directly.""" import sys home = os.path.expanduser("~") if sys.platform == "win32": base = os.path.join(os.environ.get("APPDATA", ""), "Opera Software", "Opera Stable") elif sys.platform == "darwin": base = os.path.join(home, "Library", "Application Support", "com.operasoftware.Opera") else: base = os.path.join(home, ".config", "opera") if os.path.isdir(base) and os.listdir(base): logger.info("Auto-detected Opera profile: %s", base) return base return None def find_edge_profile() -> str | None: """Auto-detect Edge profile (User Data dir) cross-platform. Returns the User Data parent dir (NOT the Default subdirectory).""" import sys home = os.path.expanduser("~") if sys.platform == "win32": base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") elif sys.platform == "darwin": base = os.path.join(home, "Library", "Application Support", "Microsoft Edge") else: base = os.path.join(home, ".config", "microsoft-edge") if os.path.isdir(os.path.join(base, "Default")): logger.info("Auto-detected Edge profile: %s", base) return base return None # ── Profile Copying ───────────────────────────────────────────────── # Both functions copy essential cookies/login/storage files from the user's # real browser profile to a temp directory. Playwright then uses these temp # profiles via launch_persistent_context(), preserving Facebook login state # without modifying the user's actual profile. def copy_firefox_profile(src_path: str) -> str: """Copy essential Firefox profile files (cookies, localStorage, permissions) to a temp dir for Playwright. Also creates a minimal profiles.ini so Firefox treats it as a valid profile.""" 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 Chromium-based profile (Chrome/Edge/Opera) to a temp dir. Copies Cookies, Login Data, Bookmarks, Web Data, Local Storage, Session Storage, and Local State. Args: user_data_dir: Parent directory (e.g. ".../Chrome/User Data") profile_dir: Subdirectory name, typically "Default" (default) """ 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 via Chromium. Cached after first download to avoid re-downloading on every scrape.""" 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 # ── Agent Output Parsing ────────────────────────────────────────────── # When the browser-use Agent fallback is used, it returns natural language # output containing post listings. This function tries to extract structured # data from that output, first attempting JSON parsing, then falling back to # a line-by-line heuristic (numbered or bulleted items). def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]: """Parse posts from agent output or page text fallback.""" posts = [] 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 # ── Keyword Filters for Lead Detection ────────────────────────────── # BROAD_KEYWORDS — catch any post that might relate to web development needs. # OFFER_PATTERNS — regex patterns matching service offers (which we ignore). # REQUEST_PATTERNS — regex patterns matching people asking for help/services. # Together these form a two-pass filter + AI classification pipeline. 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", ] # ── Anti-Detection Scripts ──────────────────────────────────────────── # These JS scripts are injected into every page via add_init_script(). # They override navigator properties (webdriver, userAgent, plugins, etc.) # and WebGL/canvas/audio fingerprints to make Playwright harder to detect. # Firefox and Chromium variants differ slightly (e.g. Chrome needs the # "window.chrome" object; Firefox must NOT have it). 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]}] });""" # ── Text Classification Utilities ──────────────────────────────────── 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: """Detect if text contains a request for services (e.g. "I need", "looking for").""" t = text.lower() return any(re.search(p, t) for p in REQUEST_PATTERNS) def is_offer(text: str) -> bool: """Detect if text is an offer/advertisement (we skip these, not leads).""" t = text.lower() return any(re.search(p, t) for p in OFFER_PATTERNS) # ── Facebook Search Queries ─────────────────────────────────────────── # These are the search terms the scraper will use on Facebook. # They target people looking for web development / website services. # Each run picks 2-4 random queries to vary behavior and reduce detection. FB_SEARCHES = [ "looking for web developer", "need someone to build my website", "need website for my small business", "who can build me a website", "recommend a web developer", "I need a website for my", "need a site for my business", "looking for web designer", "help me build a website", "need someone to design my website", "anyone know a web developer", "need a web developer for my project", "need website for my startup", "want a website for my company", "need a new website for my", "looking for web developer for my business", "need ecommerce website for my", "looking for affordable web design", "need wordpress website", "who can design a website for my", "looking for someone to create a website", "recommendations for a web designer", "I need a website", "I need a website built", "need wordpress site", "need a custom website", "I need a new website", "need an online store", "need an online shop", "need website help", "who can help me with my website", "looking for a website designer", "need a shopify website", "looking for website design", "want to build a website for my", ] VIEWPORTS = [ {'width': 1280, 'height': 800}, {'width': 1366, 'height': 768}, {'width': 1440, 'height': 900}, {'width': 1536, 'height': 864}, {'width': 1920, 'height': 1080}, ] # ── Cookie Extraction ──────────────────────────────────────────────── # Reads Facebook cookies from the browser profile's SQLite database. # Firefox stores cookies in cookies.sqlite; Chromium stores them in Cookies # (a SQLite DB as well). This function handles Firefox's format. # The cookies are copied to a temp file first to avoid locking issues. 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 [] # ── Date Parsing ───────────────────────────────────────────────────── # Facebook displays dates in relative format ("2h ago", "Yesterday", "Monday") # or various absolute formats. These functions normalize them to YYYY-MM-DD. 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) # ── Post Extraction ────────────────────────────────────────────────── # Two strategies for extracting posts from page content: # 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements() # 2. _extract_posts_from_text — fallback that parses raw page text line by line # Both apply dedup (seen_texts), offer filtering, and request scoring. 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