Files
CRM_ENVR/browser-use-service/main.py
T
2026-07-07 10:08:21 +02:00

2172 lines
100 KiB
Python

# ── 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=os.getenv("CORS_ORIGINS", "http://localhost:3006,http://127.0.0.1:3006").split(","),
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"\bfree\s+\w+\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",
r"\bdo\s+you\s+(need|want)\b",
r"\bwe\s+(can|will)\s+(build|design|create|develop|do)\b",
r"\bi\s+(can|will)\s+(build|design|create|develop|do)\b",
r"\bmy\s+portfolio\b",
r"\breasonable\s+(price|pricing|rate)\b",
r"\bwhatsapp\s+(me|us|at)\b",
r"\bwhatsapp\b",
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
r"\bhelp\s+your\s+business\b",
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
r"\bcontact\s+me\b",
r"\bwe\s+(are|offer|provide)\s+(web|website|design|service)\b",
r"\btake\s+(the\s+|this\s+)?quiz\b",
r"\bhomeschool\b",
r"\byour\s+(home\s+)?tutor\b",
r"\blink\s+(in\s+)?(bio|comment|below)\b",
r"\bapply\s+now\b",
r"\bget\s+started\b",
r"\bfor\s+only\b",
r"\blow\s+(price|cost|rate)\b",
r"\bhit\s+me\s+up\b",
r"\bsend\s+me\s+(a\s+)?(message|dm|pm)\b",
r"\bi\s+do\s+(web|website|design|development)\b",
r"\bwe\s+do\s+(web|website|design|development)\b",
r"\bi'm\s+offering\b",
r"\bwe're\s+offering\b",
r"\bstarting\s+a\s+\w+\s+(service|business)\b",
r"\blooking\s+for\s+a\s+few\s+businesses?\b",
r"\blet('?)s\s+connect\b",
r"\bi\s+have\s+projects?\b",
r"\byour\s+business\b",
r"\bwordpress\s+development\b",
r"\be.?commerce\s+website\b",
r"\bwebsite\s+builder\b",
r"\bai\s+studio\b",
r"\bpage\s+speed\b",
r"\bguest\s+post",
r"\bguest\s+blog",
r"\bfor\s+sale\b",
r"\bselling\s+(my|a|the|this)\b",
r"\bpremium\b",
r"\bi'm\s+selling\b",
r"\bgroup\b",
r"\bi\s+can\s+help\b",
r"\binbox\s+me\b",
r"\bpm\s+me\b",
r"\bdm\s+me\b",
r"\bmessage\s+me\s+for\b",
r"\bquote\b",
r"\bdiscount\b",
r"\bbest\s+(deal|price|offer|service)\b",
r"\bprice\s+(start|begin|include|range)\b",
r"\breach\s+out\b",
r"\bclick\s+(the\s+)?link\b",
r"\bcheck\s+(out|this|my)\b",
r"\bwebsite\s+for\s+your\b",
r"\bprobono\b",
r"\bfreelance\s+opportunit",
r"\bfull.?stack\b",
r"\bresponsive\s+(web)?sites?\b",
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
r"\bmern\s+stack\b",
r"\bexpress\s+js\b",
r"\breact\s+js\b",
r"\bnode\s+js\b",
r"\bwebsite\s+for\s+\$\b",
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
r"\bfull.?time\s+(position|job|role|work)\b",
r"\bremote\s+(position|job|role|work)\b",
r"\bhelp\s+wanted\b",
r"\bjob\s+(opening|vacancy|opportunity)\b",
r"\bnow\s+hiring\b",
r"\bpart.?time\s+(position|job|role|work)\b",
r"\byears?\s+of\s+(teaching|experience)\b",
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
r"\bcomment\s+.*\bsend\b",
r"\bfor\s+free\b",
r"\bmake\s+money\b",
r"\bno\s+coding\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<a.length;i++) a[i]+=(Math.random()-0.5)*0.02; }; }
Object.defineProperty(screen, 'colorDepth', { get: () => 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<a.length;i++) a[i]+=(Math.random()-0.5)*0.02; }; }
Object.defineProperty(screen, 'colorDepth', { get: () => 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",
]
TUTORING_SEARCHES = [
"need a tutor",
"looking for a tutor",
"tutor for my child",
"need help with homework",
"private tutor needed",
"looking for tutoring",
"need a math tutor",
"english tutor needed",
"online tutor for",
"lessons for my child",
"help my child with",
"need someone to tutor",
"looking for someone to teach",
"tutoring for my",
"need help learning",
"recommend a tutor",
"anyone know a tutor",
"need a private tutor",
"tutoring services for my child",
"need academic help",
"need a reading tutor",
"looking for piano teacher",
"need help with maths",
"need a science tutor",
"looking for language tutor",
"need programming tutor",
"coding tutor for my",
"sat tutor needed",
"exam preparation tutor",
"need a homeschool tutor",
"tutor near me for",
]
def _search_list_for_query(query: str) -> list[str]:
"""Pick the appropriate search query pool based on the search term."""
tl = query.lower()
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
if any(t in tl for t in tutoring_terms):
return TUTORING_SEARCHES
return FB_SEARCHES
# ── South African Multi-Language Queries ──────────────────────────────
# 4 most spoken SA languages: English, Afrikaans, isiXhosa, isiZulu.
# Each scrape searches ALL 4 languages to catch leads across all
# language communities on Facebook.
SA_WEBSITE_QUERIES = [
"I need a website for my business", # English
"ek benodig n webwerf", # Afrikaans
"ek soek iemand om n webwerf te bou", # Afrikaans
"ndidinga iwebhusayithi yeshishini", # isiXhosa
"ndifuna umntu owakha iwebhusayithi", # isiXhosa
"ngidinga iwebhusayithi yebhizinisi", # isiZulu
"ngifuna umuntu owakha iwebhusayithi", # isiZulu
]
SA_TUTOR_QUERIES = [
"I need a tutor for my child", # English
"ek benodig n tutor vir my kind", # Afrikaans
"ek soek n privaat onderwyser", # Afrikaans
"ndifuna utitshala womntwana wam", # isiXhosa
"ndidinga umfundisi-ntsapho", # isiXhosa
"ngidinga uthisha wengane yami", # isiZulu
"ngifuna umfundisi wengane", # isiZulu
]
async def _quick_search(page, context, query: str) -> tuple:
"""Fast search — load search results page, wait for render, extract visible posts.
No scrolling or extra human-like delays. Used for non-English language queries."""
page = await _ensure_page(page, context)
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=20000)
current_url = page.url
if '/login' in current_url.lower():
logger.warning("Quick search redirected to login for '%s'", query[:40])
return page, []
await page.wait_for_timeout(random.randint(3000, 7000))
await page.evaluate(f"window.scrollBy(0, {random.randint(400, 900)})")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.35:
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 400)})")
await page.wait_for_timeout(random.randint(1500, 3500))
await page.evaluate(f"window.scrollBy(0, {random.randint(400, 900)})")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(1000, 3000))
raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
raw = await page.evaluate('document.body.innerText')
text_posts = _extract_posts_from_text(raw, url)
existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
for tp in text_posts:
key = (tp.get('title') or tp.get('content',''))[:80]
if key not in existing:
posts.append(tp)
if posts:
try:
profiles = await page.evaluate(r'''() => {
const out = [];
const seenTxt = new Set();
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
const name = (a.innerText || '').trim();
if (!name || name.length < 3 || name.length > 60) continue;
const words = name.split(' ');
if (words.length < 2 || words.length > 6) continue;
if (!/^[A-Z]/.test(name)) continue;
if (name.includes('facebook') || name.includes('/')) continue;
const cell = a.closest('div[style]') || a.parentElement;
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
if (!txt) continue;
const key2 = txt.substring(0, 80);
if (seenTxt.has(key2)) continue;
seenTxt.add(key2);
out.push({ name, textKey: key2 });
}
return out;
}''')
if profiles:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:80].strip()
if not pk: continue
for pr in profiles:
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
if not p.get('author'):
p['author'] = pr['name']
break
except Exception:
pass
for p in posts:
if p.get('author'):
a = p['author']
al = a.lower()
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
p['author'] = ''
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Quick search '%s' failed: %s", query, e)
return page, []
return page, posts
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 = 2) -> 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)
# Words that should never be treated as person names
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
# ── 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.
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = []
seen_texts = set()
now = datetime.utcnow()
for el in elements:
# Reject group posts immediately
if el.get('isGroup'):
continue
# Reject posts older than 2 days
ts = el.get('ts', 0)
if ts:
age_seconds = (now.timestamp() * 1000 - ts) / 1000
if age_seconds > 172800: # 2 days
continue
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)
# Reject if text contains group-like patterns
lower = text.lower()
if ' / ' in lower:
continue
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
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:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d')
except (ValueError, TypeError):
date_str = _parse_fb_date([js_date])
else:
date_str = _parse_fb_date(lines)
posts.append({
"title": text[:300],
"content": text[:1000],
"author": el.get('author', ''),
"url": post_url,
"source": "facebook",
"date": date_str,
"_score": request_score,
})
posts.sort(key=lambda p: p.get('_score', 0), reverse=True)
for p in posts:
p.pop('_score', None)
return posts
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
lines = [l.strip() for l in raw.split('\n') if l.strip() and len(l.strip()) >= 15]
posts = []
seen_texts = set()
cur = []
def _find_author(candidate_lines: list[str]) -> str:
for line in reversed(candidate_lines):
ln = line.strip()
if not ln or len(ln) > 60 or len(ln) < 3:
continue
words = ln.split()
if not (1 < len(words) < 8):
continue
if not all(w[0].isalpha() and w[0].isupper() for w in words if w):
continue
lower_ln = ln.lower()
if any(kw in lower_ln for kw in BROAD_KEYWORDS) or is_offer(ln):
continue
if 'http' in ln or '/' in ln or '@' in ln:
continue
return ln
return ''
for l in lines:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) < 2:
continue
lower = l.lower()
if any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(l):
if cur:
snippet = ' '.join(cur[-3:] + [l])
if len(snippet) >= 40 and not is_offer(snippet):
dekey = snippet[:80]
if dekey not in seen_texts:
seen_texts.add(dekey)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": _find_author(cur[-3:]),
"url": url,
"source": "facebook",
"date": _parse_fb_date(cur + [l]),
})
cur = []
else:
if not is_offer(l):
snippet = l
if len(snippet) >= 40:
dekey = snippet[:80]
if dekey not in seen_texts:
seen_texts.add(dekey)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": '',
"url": url,
"source": "facebook",
"date": _parse_fb_date([l]),
})
cur.append(l)
if len(cur) > 10:
cur.pop(0)
# Post-process: scan each post's text for inline names
def _scan_inline_name(text: str) -> str:
for m in re.finditer(r'([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,}){1,3})[\s,;:]', text):
cand = m.group(1).strip()
if cand and 3 <= len(cand) <= 60:
lower = cand.lower()
words = lower.split()
if not any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(cand):
if not any(w in _NON_NAMES for w in words):
return cand
return ''
for p in posts:
if not p.get('author'):
txt = p.get('content') or p.get('title') or ''
name = _scan_inline_name(txt)
if name:
p['author'] = name
# Remove group posts: any post whose text looks like it came from a group
filtered = []
for p in posts:
t = (p.get('title') or p.get('content') or '')
if ' / ' in t:
continue
lower = t.lower()
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
filtered.append(p)
# Dedup: merge posts where one is a suffix of another (same post split)
merged = []
for p in filtered:
t = (p.get('title') or p.get('content') or '').lower()
# Check if this post is substantially contained in an already-merged post
found = False
for i, m in enumerate(merged):
mt = (m.get('title') or m.get('content') or '').lower()
# Word-level overlap: count shared words
twords = set(t.split())
mwords = set(mt.split())
if len(twords) > 3 and len(mwords) > 3:
overlap = len(twords & mwords)
smaller = min(len(twords), len(mwords))
if overlap / smaller >= 0.6:
# Keep the longer one
if len(t) > len(mt):
merged[i] = p
found = True
break
if not found:
merged.append(p)
return merged
# ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns
# to make automated browsing look more like a real human user.
# This is critical for avoiding Facebook's bot detection.
async def human_scroll(page, steps: int = None, total_delay: float = None):
steps = steps or random.randint(2, 5)
total_delay = total_delay or random.uniform(6, 18)
step_delay = total_delay / steps
for i in range(steps):
scroll_dist = random.randint(200, 600)
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
try:
vp = await page.evaluate('({w: window.innerWidth, h: window.innerHeight})')
x = random.randint(100, vp['w'] - 100)
y = random.randint(100, vp['h'] - 100)
await page.mouse.move(x, y, steps=random.randint(15, 35))
except Exception:
pass
await page.wait_for_timeout(int(step_delay * 1000))
if random.random() < 0.3:
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
await page.wait_for_timeout(random.randint(1000, 3000))
async def random_idle(page):
action = random.random()
if action < 0.15:
try:
elems = await page.query_selector_all('a, span, div[role="button"]')
if elems:
el = random.choice(elems[:20])
box = await el.bounding_box()
if box:
x = box['x'] + box['width'] / 2 + random.randint(-20, 20)
y = box['y'] + box['height'] / 2 + random.randint(-10, 10)
await page.mouse.move(x, y, steps=random.randint(20, 40))
await page.wait_for_timeout(random.randint(500, 2000))
except Exception:
pass
# ── Facebook DOM Parsing ─────────────────────────────────────────────
# This JS function runs inside the page context to extract structured post
# data (text, author, URL, date) from Facebook's complex DOM structure.
# It tries multiple selectors (article, feed, etc.) and uses the first
# one that returns results, since Facebook's DOM changes frequently.
async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => {
const results = [];
const seenTexts = new Set();
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
const now = Date.now();
const DAY_MS = 86400000;
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
for (const a of anchors) {
const h = a.getAttribute('href') || '';
if (!h) continue;
const cell = a.closest('div[style]') || a.parentElement;
if (!cell) continue;
const txt = (cell.innerText || '').trim();
if (txt.length < 40) continue;
const lower = txt.toLowerCase();
let matched = false;
for (const t of terms) { if (lower.includes(t)) { matched = true; break; } }
if (!matched) continue;
const key = txt.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
const isGroup = h.includes('/groups/');
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60 || author.length < 2) author = '';
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
let date = '';
let ts = 0;
const timeEl = cell.querySelector('time');
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
const dtVal = timeEl.getAttribute('datetime');
if (dtVal) { ts = new Date(dtVal).getTime(); }
}
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
}
return results;
}''')
async def _ensure_page(page, context):
"""Check if the current page is still alive. If closed (e.g. by a popup),
create a fresh page and navigate to Facebook."""
try:
await page.evaluate('1')
return page
except Exception:
logger.warning("Page was closed mid-scrape, creating a fresh page")
page = await context.new_page()
try:
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception:
logger.warning("Google navigation failed during page recreation")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
return page
# ── Facebook Search & Scrape ─────────────────────────────────────────
# Performs a Facebook search for a given query, scrolls through results,
# extracts posts, and returns them. Includes randomization of scroll behavior,
# mouse movements, and idle actions to mimic human browsing patterns.
async def search_facebook(page, context, query: str):
page = await _ensure_page(page, context)
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(5000, 10000))
await human_scroll(page, steps=random.randint(4, 7), total_delay=random.uniform(12, 25))
if random.random() < 0.3:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(random.randint(1000, 3000))
await page.evaluate("window.scrollBy(0, -300)")
await page.wait_for_timeout(random.randint(1000, 3000))
if random.random() < 0.2:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.3:
await random_idle(page)
raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
raw = await page.evaluate('document.body.innerText')
text_posts = _extract_posts_from_text(raw, url)
existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
for tp in text_posts:
key = (tp.get('title') or tp.get('content',''))[:80]
if key not in existing:
posts.append(tp)
if posts:
# Extract author names from profile links in the DOM
try:
profiles = await page.evaluate(r'''() => {
const out = [];
const seenTxt = new Set();
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
const name = (a.innerText || '').trim();
if (!name || name.length < 3 || name.length > 60) continue;
const words = name.split(' ');
if (words.length < 2 || words.length > 6) continue;
if (!/^[A-Z]/.test(name)) continue;
if (name.includes('facebook') || name.includes('/')) continue;
const cell = a.closest('div[style]') || a.parentElement;
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
if (!txt) continue;
const key = txt.substring(0, 80);
if (seenTxt.has(key)) continue;
seenTxt.add(key);
out.push({ name, textKey: key });
}
return out;
}''')
if profiles:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:80].strip()
if not pk: continue
for pr in profiles:
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
if not p.get('author'):
p['author'] = pr['name']
break
except Exception:
pass
try:
meta = await page.evaluate(r'''() => {
const out = [];
const seenHref = new Set();
const all = document.querySelectorAll('a[href]');
for (const a of all) {
const h = a.href;
if (!h || seenHref.has(h)) continue;
const u = new URL(h);
if (!u.hostname.includes('facebook.com') && !u.hostname.includes('fb.com')) continue;
const path = u.pathname;
if (path === '/' || path === '/search/' || path === '/marketplace/' || path.startsWith('/messages/') || path.startsWith('/notifications/') || path.startsWith('/settings/')) continue;
const isPost = path.includes('/posts/') || path.includes('/videos/') || path.includes('/photos/') || path.startsWith('/story.php') || path.startsWith('/photo.php') || path.startsWith('/watch') || path.includes('/reel/') || path.startsWith('/permalink/');
if (!isPost) continue;
const cell = a.closest('[style*="position"], [role="article"], div[data-pagelet], div.x1yztbdb, div.x78zum5, div.x1n2onr6') || a.parentElement;
if (!cell) continue;
const txt = (cell.innerText || '').trim();
if (txt.length < 40) continue;
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60) author = '';
const key = txt.substring(0, 100);
seenHref.add(h);
out.push({ key, href: h, author, textMap: txt.substring(0,200) });
}
return out;
}''')
if meta:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:100].strip()
if not pk: continue
for m in meta:
mk = m.get('key') or ''
if pk[:40] in mk or mk[:40] in pk:
if m.get('href') and 'search/' not in m['href']:
p['url'] = m['href']
if m.get('author') and not p.get('author'):
p['author'] = m['author']
break
except Exception:
pass
# Clean up: remove group names passed off as authors
for p in posts:
if p.get('author'):
a = p['author']
al = a.lower()
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
p['author'] = ''
# Final filter: strip any remaining group posts
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
return page, []
return page, posts
# ── Detection Signals ────────────────────────────────────────────────
# Keywords/phrases in page URL or body text that indicate Facebook's
# security/bot detection systems have flagged the session.
DETECTION_SIGNALS = [
'/checkpoint/', '/login.php?', 'action=security_check',
'unusual activity', 'suspicious login', 'suspicious activity',
'temporarily blocked', 'temporarily restricted',
'confirm your identity', 'enter the code we sent',
'we noticed unusual', 'help us confirm',
'security check', 'challenge', 'please verify',
]
def check_detection_signals(page_url: str, page_text: str = '') -> str | None:
"""Check page for Facebook detection signals. Returns signal text or None."""
combined = (page_url + ' ' + page_text).lower()
for signal in DETECTION_SIGNALS:
if signal in combined:
return signal
return None
def cleanup_chrome():
"""Kill orphaned Chrome headless shell processes. These sometimes linger
after failed scrapes and interfere with subsequent runs."""
import subprocess, signal
try:
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
except Exception:
pass
# ── Main Scrape Dispatcher ────────────────────────────────────────────
# scrape_facebook() is the main entry point. It:
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect)
# 2. Dispatches to the correct browser-specific scraper
# 3. If the browser scrape is flagged by Facebook, falls through to the Agent fallback
#
# Priority order for browser selection:
# - SELECTED_BROWSER env var (set by setup wizard) → that browser's scraper
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
# - If no profile found → Agent (browser-use + ChatOllama) fallback
async def scrape_facebook(profile_path: str | None = None, force: bool = False, query: str | None = None) -> dict:
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
effective_path = profile_path or ""
browser_type = ""
# Resolve profile and browser type
if effective_path:
browser_type = detect_browser_from_profile(effective_path) or ""
elif SELECTED_BROWSER == "firefox":
effective_path = FX_PROFILE or find_firefox_profile() or ""
browser_type = "firefox"
elif SELECTED_BROWSER == "opera":
effective_path = OPERA_PROFILE or find_opera_profile() or ""
browser_type = "opera"
elif SELECTED_BROWSER == "edge":
effective_path = EDGE_PROFILE or find_edge_profile() or ""
browser_type = "edge"
elif SELECTED_BROWSER == "chrome":
effective_path = CHROME_PROFILE or find_chrome_profile() or ""
browser_type = "chrome"
else:
# Auto-detect — try all in priority order
for name, env_var, find_fn in [
("firefox", "FX_PROFILE", find_firefox_profile),
("opera", "OPERA_PROFILE", find_opera_profile),
("chrome", "CHROME_PROFILE", find_chrome_profile),
("edge", "EDGE_PROFILE", find_edge_profile),
]:
p = os.getenv(env_var, "") or find_fn() or ""
if p:
effective_path = p
browser_type = name
break
if not browser_type or not effective_path:
logger.info("No profile found — falling back to Agent")
return await _scrape_with_agent(force)
logger.info("Selected browser: %s (profile: %s)", browser_type, effective_path)
# Firefox path
if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force, query)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# ── Firefox Scraper ──────────────────────────────────────────────────
# Launches a headless Firefox with the user's real profile cookies (copied
# to a temp dir). Uses firefox_user_prefs to disable automation flags and
# tracking protection. Injects anti-detection script via add_init_script().
# If force=False, randomly skips 30% of runs as a decoy (looks like random
# human browsing). If flagged by Facebook, returns flagged=True for the
# dispatcher to fall through to the Agent.
async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None = None) -> dict:
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
profile_dir = None
try:
profile_dir = copy_firefox_profile(profile_path)
async with async_playwright() as pw:
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
viewport=random.choice(VIEWPORTS),
firefox_user_prefs={
"dom.webdriver.enabled": False,
"dom.webdriver.timeout": 0,
"useAutomationExtension": False,
"privacy.trackingprotection.enabled": False,
"privacy.trackingprotection.fingerprinting.enabled": False,
"privacy.trackingprotection.cryptomining.enabled": False,
},
)
pages = context.pages
page = pages[0] if pages else await context.new_page()
await context.add_init_script(firefox_init_script())
try:
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
tutoring = False
if query:
tl = query.lower()
tutoring = any(t in tl for t in ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child"])
lang_pool = SA_TUTOR_QUERIES if tutoring else SA_WEBSITE_QUERIES
non_english = [q for q in lang_pool if q.strip().lower() != tl]
query_pool = _search_list_for_query(query)
supp_k = random.randint(3, 4) if tutoring else random.randint(2, 3)
supplement = random.sample(query_pool, k=supp_k)
searches = [query] + supplement + non_english
english_count = 1 + len(supplement)
else:
searches = random.sample(FB_SEARCHES + SA_WEBSITE_QUERIES + SA_TUTOR_QUERIES, k=random.randint(4, 7))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq) if i == 0 else await _quick_search(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
if i > 0:
await page.wait_for_timeout(random.uniform(5000, 12000))
if random.random() < 0.2:
await random_idle(page)
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
# Filter to last 2 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads, tutoring=tutoring)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("Firefox scrape failed: %s", e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
finally:
if profile_dir and os.path.exists(profile_dir):
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception:
pass
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
# Firefox scraper but with Chromium-specific launch config:
# - Chrome: channel="chrome"
# - Edge: channel="msedge"
# - Opera: executable_path from shutil.which("opera")
# Cookies come from copy_chrome_profile (temp copy of user's profile data).
# Anti-detection script differs slightly from Firefox variant.
# Same decoy-skip and flagged-fallback behavior as Firefox.
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False, query: str | None = None) -> dict:
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
channel = None
executable_path = None
if browser == "chrome":
channel = "chrome"
elif browser == "edge":
channel = "msedge"
elif browser == "opera":
executable_path = shutil.which("opera") or shutil.which("Opera")
profile_dir = None
try:
profile_dir = copy_chrome_profile(profile_path)
async with async_playwright() as pw:
launch_kwargs = dict(
user_data_dir=profile_dir,
headless=True,
viewport=random.choice(VIEWPORTS),
args=CHROME_LAUNCH_ARGS,
)
if channel:
launch_kwargs["channel"] = channel
if executable_path:
launch_kwargs["executable_path"] = executable_path
context = await pw.chromium.launch_persistent_context(**launch_kwargs)
pages = context.pages
page = pages[0] if pages else await context.new_page()
await context.add_init_script(chromium_init_script())
try:
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
tutoring = False
if query:
tl = query.lower()
tutoring = any(t in tl for t in ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child"])
lang_pool = SA_TUTOR_QUERIES if tutoring else SA_WEBSITE_QUERIES
non_english = [q for q in lang_pool if q.strip().lower() != tl]
query_pool = _search_list_for_query(query)
supp_k = random.randint(3, 4) if tutoring else random.randint(2, 3)
supplement = random.sample(query_pool, k=supp_k)
searches = [query] + supplement + non_english
english_count = 1 + len(supplement)
else:
searches = random.sample(FB_SEARCHES + SA_WEBSITE_QUERIES + SA_TUTOR_QUERIES, k=random.randint(4, 7))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq) if i == 0 else await _quick_search(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
if i > 0:
await page.wait_for_timeout(random.uniform(5000, 12000))
if random.random() < 0.2:
await random_idle(page)
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads, tutoring=tutoring)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("%s scrape failed: %s", browser, e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
finally:
if profile_dir and os.path.exists(profile_dir):
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception:
pass
# ── Agent Fallback ──────────────────────────────────────────────────
# When all browser-based scrapers fail/are flagged, this fallback uses
# browser-use Agent with ChatOllama (local, free) to navigate Facebook
# autonomously via GPT-style prompting. No API keys needed.
# Uses Chromium headless with the same launch args as _scrape_with_chromium.
# The Agent is prompted to extract structured post data and return JSON.
async def _scrape_with_agent(force: bool = False) -> dict:
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
cleanup_chrome()
profile_dir = None
try:
user_data_dir = None
if CHROME_PROFILE:
profile_dir = copy_chrome_profile(CHROME_PROFILE)
user_data_dir = profile_dir
browser = _make_browser(
headless=True,
args=CHROME_LAUNCH_ARGS,
user_data_dir=user_data_dir,
allowed_domains=["*.facebook.com", "*.messenger.com", "www.google.com"],
)
await browser.start()
all_posts = []
pool = FB_SEARCHES + random.sample(SA_WEBSITE_QUERIES, k=min(4, len(SA_WEBSITE_QUERIES)))
for query in random.sample(pool, k=random.randint(2, 4)):
agent = _make_agent(
task=f"""You are logged into Facebook. Do the following:
1. Navigate to facebook.com and make sure you are on the homepage
2. Use the Facebook search to find: {query}
3. Scroll through the results
4. For each relevant post, extract and list:
- The publisher/author name (who posted it)
- The post text content
- The post URL (if visible)
- The post date
5. ONLY include posts from the last 2 days
6. Collect as many posts as you can (aim for 5-10 per search)
When done, return the data as a JSON list with keys: content, author, url, date.""",
llm=_make_ollama(num_ctx=32000, temperature=0.3),
browser=browser,
max_actions_per_step=3,
use_vision=False,
max_failures=6,
step_timeout=180,
)
history = await agent.run()
result = history.final_result() or ""
posts = extract_agent_posts(result, "")
all_posts.extend(posts)
if random.random() < 0.3:
await asyncio.sleep(random.randint(5, 15))
await browser.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
# Filter to last 2 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads, tutoring=tutoring)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("Agent scrape failed: %s", e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
finally:
if profile_dir and os.path.isdir(profile_dir):
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception as e:
logger.warning("Failed to clean up Chrome profile %s: %s", profile_dir, e)
# ── AI Classification ────────────────────────────────────────────────
# Uses the local Ollama model to classify scraped posts as LEAD or NOT LEAD.
# Falls back to keyword-based filtering if the AI is unavailable.
# ask_ollama() sends a raw prompt to Ollama's /api/chat endpoint.
# classify_leads() builds a classification prompt and parses the JSON response.
async def ask_ollama(prompt: str) -> str:
import httpx
async with httpx.AsyncClient(timeout=120) as c:
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
"model": CLASSIFY_MODEL,
"messages": [
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
{"role": "user", "content": prompt}
],
"stream": False,
"options": {"temperature": 0.05, "num_predict": 1024},
})
r.raise_for_status()
data = r.json()
return data["message"]["content"]
async def classify_leads(results: list[dict], tutoring: bool = False) -> list[dict]:
if not results:
return []
# ── 1. AI classification ─────────────────────────────────────────
briefs = [r["title"][:200] for r in results]
if tutoring:
lead_desc = "someone REQUESTING/LOOKING FOR/WANTING a tutor, teacher, or lessons for their child or themselves"
lead_examples = '"Looking for a tutor for my child", "Need a math tutor for my son", "Need help with homework", "Looking for piano lessons for my daughter", "Need a reading tutor"'
not_lead_examples = '"I offer tutoring services", "I am a tutor with experience", "Affordable tutoring packages", "Online tutor available"'
extra_terms = '- Posts about homeschooling resources, curriculum sales, or educational products\n- Posts asking for study tips or general academic advice without requesting a tutor'
else:
lead_desc = "someone REQUESTING/POSTING/WANTING a website built, designed, or created for them"
lead_examples = '"Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"'
not_lead_examples = '"I build websites", "I offer web design", "Affordable web design packages"'
extra_terms = '- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"'
prompt = f"""Classify each post as LEAD or NOT.
LEAD = {lead_desc}.
LEAD examples: {lead_examples}
NOT LEAD:
- Offering services: {not_lead_examples}
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
- Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers
{extra_terms}
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
- Vague questions or general recommendations without a clear intent to buy or hire
- People asking how to learn or do it themselves (not looking to hire someone)
- Posts about existing website issues like speed, SEO, errors, redesign advice — NOT a lead
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_leads: list[dict] = []
try:
raw = await ask_ollama(prompt)
raw = raw.strip()
if raw.startswith("```"):
first_nl = raw.find('\n')
if first_nl != -1:
raw = raw[first_nl + 1:]
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results):
for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes':
ai_leads.append(results[i])
logger.info("AI classified %d/%d as LEAD", len(ai_leads), len(results))
except Exception as e:
logger.warning("AI classification failed: %s", e)
# ── 2. Keyword supplement (never overrides AI, only adds missing leads) ──
if tutoring:
target_terms = [
"tutor", "tutoring", "tutor for", "private tutor",
"math tutor", "english tutor", "reading tutor",
"science tutor", "online tutor", "home tutor",
"lessons for", "lessons for my", "piano lessons",
"swimming lessons", "music lessons",
"help with homework", "homework help",
"teacher for", "teacher for my",
"need help learning", "need help with",
"exam prep", "exam preparation",
"homeschool", "homeschool tutor",
"tuition",
"coding for my", "programming for my",
"looking for a tutor", "need a tutor",
"tutor needed", "tutoring for",
"private lessons", "private tuition",
"afterschool", "after school",
"extra classes", "extra lessons",
]
offer_reject_tutor = [
'i am a tutor', "i'm a tutor", 'i offer tutoring',
'online tutor available', 'tutor available',
'i teach', 'i provide tutoring',
'affordable tutoring', 'tutoring services',
'experienced tutor', 'qualified tutor',
'your child', 'your kids', 'your children',
'enroll your', 'sign up',
'free trial', 'first lesson free',
'group lessons', 'group class',
'limited spots', 'book now',
'curriculum', 'workbook', 'worksheet',
'educational program',
'homeschool program', 'home school program',
]
else:
target_terms = [
"website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website",
"landing page", "wordpress", "ecommerce",
"my website", "business website",
"site for my", "site for my business",
"new website", "redesign my website",
"help with my website", "update my website",
"make a website", "make my website",
"website for my",
"online store", "online shop",
"build my site", "build a site",
"set up a website", "set up my website",
"custom website",
"shopify",
"my site",
"webpage", "web page",
"who can build", "who can design",
"create my website", "create my site",
]
offer_reject_tutor = []
request_terms = [
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
"who can", "i need",
"recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation",
"suggest", "looking for recommendations",
"can anyone", "does anyone",
"dm me", "pm me", "message me",
"quote for",
"can you help",
"how much",
"price for",
]
keyword_leads: list[dict] = []
offer_reject = [
'i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites',
'do you need a', 'do you want a', 'we can build',
'i can build', 'my portfolio', 'reasonable price',
'whatsapp me', 'looking for a business', 'looking for client',
'help your business', 'i am a web', 'contact me',
'we offer web', 'we provide web',
'take the quiz',
'link in bio', 'apply now', 'get started',
'for only', 'low price', 'hit me up',
'send me a message', 'i do website', 'we do website',
'we do web', 'i do web',
'website designer / web developer', 'website & software creators',
'website builders for small businesses',
'wix website design',
'for sale', 'selling my', 'premium',
'i\'m selling', 'i\'m offering', 'we\'re offering',
'free ecommerce', 'free website design',
'starting a', 'looking for a few businesses',
# Group-related rejections
'group', ' i need a website group',
'i can help', 'inbox me', 'message me for',
'best price', 'discount', 'reach out', 'check out my', 'check this',
'website for your', 'price start', 'price begin', 'website creator',
'website & software', 'creators &', 'creators marketplace',
'website group', 'page group',
'south africa web', 'philippines web', 'australia web',
'nigerian web', 'kenya web', 'india web',
# Self-promotion rejections
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack",
'freelance opportunity', 'looking for new project', 'looking for new work',
'full stack web', 'mern stack', 'responsive business website',
'i build website', 'i build shopify', 'i build wordpress',
'we build website', 'we build shopify', 'we build wordpress',
'i create website', 'we create website',
'full time position', 'full time job', 'remote position', 'remote job',
'help wanted', 'now hiring', 'job opening',
'website speed', 'speed optimization', 'on page seo', 'off page seo',
'elementor pro', 'woocommerce', 'acf', 'custom post type',
'send you the link', 'comment website',
'for free', 'no coding', 'make money', 'website for free',
'part time job', 'part time position',
'years of experience', 'years of teaching',
# Service offers that slip through two-word check
'i am a full stack', 'i am a developer',
'i will design', 'i will build', 'i will create',
'i can design', 'i can create',
'we will design', 'we will build',
'hire me', 'i am available for',
'available for work', 'freelance web',
'i specialize in', 'we specialize in',
"here's my portfolio", 'check my portfolio',
'see my work', 'view my work',
'we have a team', 'my team',
'i am looking for clients', 'i am looking for work',
'looking for web development work',
'looking for new clients',
# People learning / doing it themselves (not hiring)
'learn web development', 'learn to code',
'how to build a website', 'how to create a website',
'how to make a website', 'how to design a website',
'where to start', 'online course',
'want to learn', 'learning web',
'best platform for', 'which platform',
# Existing website issues (not new build)
'my website is down', 'website not loading',
'website error', 'website problem',
'website troubleshooting',
'need website advice', 'website tips',
'help with seo', 'google ranking',
'website design ideas', 'website inspiration',
]
for r in results:
t = r['title'].lower()
has_target = any(kw in t for kw in target_terms)
has_request = any(kw in t for kw in request_terms)
if not has_target or not has_request:
continue
if any(kw in t for kw in offer_reject):
continue
if any(kw in t for kw in offer_reject_tutor):
continue
keyword_leads.append(r)
# ── 3. Merge: prefer AI leads, supplement with keywords ──
seen_titles: set[int] = set()
merged: list[dict] = []
for r in ai_leads + keyword_leads:
key = hash(r.get('title', ''))
if key not in seen_titles:
seen_titles.add(key)
merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
return merged[:10]
# ══════════════════════════════════════════════════════════════════════
# FastAPI Endpoints
# ══════════════════════════════════════════════════════════════════════
@app.get("/health")
async def health():
"""Simple health check for the splash page status polling."""
return {"status": "ok"}
def _detect_all_profiles() -> dict:
"""Return all detected browser profiles."""
return {
"firefox": FX_PROFILE or find_firefox_profile() or "",
"opera": OPERA_PROFILE or find_opera_profile() or "",
"chrome": CHROME_PROFILE or find_chrome_profile() or "",
"edge": EDGE_PROFILE or find_edge_profile() or "",
}
@app.get("/setup/profile")
async def setup_profile():
"""Auto-detect all browser profiles."""
return _detect_all_profiles()
async def _check_browser_login(profile_path: str, browser: str) -> dict:
"""Check Facebook login for a specific browser type."""
profile_dir = None
try:
if browser == "firefox":
profile_dir = copy_firefox_profile(profile_path)
async with async_playwright() as pw:
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir, headless=True,
firefox_user_prefs={"dom.webdriver.enabled": False},
)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
logged_in = "/login" not in page.url.lower()
await context.close()
shutil.rmtree(profile_dir, ignore_errors=True)
return {"logged_in": logged_in, "browser": browser, "path": profile_path}
else:
channel = None
exe = None
if browser == "chrome":
channel = "chrome"
elif browser == "edge":
channel = "msedge"
elif browser == "opera":
exe = shutil.which("opera") or shutil.which("Opera")
profile_dir = copy_chrome_profile(profile_path)
async with async_playwright() as pw:
kw = dict(user_data_dir=profile_dir, headless=True, args=CHROME_LAUNCH_ARGS)
if channel:
kw["channel"] = channel
if exe:
kw["executable_path"] = exe
context = await pw.chromium.launch_persistent_context(**kw)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
logged_in = "/login" not in page.url.lower()
await context.close()
shutil.rmtree(profile_dir, ignore_errors=True)
return {"logged_in": logged_in, "browser": browser, "path": profile_path}
except Exception as e:
if profile_dir:
try: shutil.rmtree(profile_dir, ignore_errors=True)
except: pass
return {"logged_in": False, "browser": browser, "path": profile_path, "error": str(e)}
@app.post("/setup/check-login")
async def setup_check_login(body: dict):
"""Check if Facebook is logged in. Accepts optional 'browser' param, tries all if not given."""
browser = (body.get("browser") or "").strip().lower()
profile_path = (body.get("profile_path") or "").strip()
if browser and profile_path:
return await _check_browser_login(profile_path, browser)
# No specific browser — try all detected
profiles = _detect_all_profiles()
priority = ["firefox", "opera", "chrome", "edge"]
for b in priority:
p = profiles.get(b, "")
if p:
result = await _check_browser_login(p, b)
if result.get("logged_in"):
return result
logger.info("%s not logged in, trying next", b)
return {"logged_in": False, "reason": "none_logged_in", "message": "No browser with Facebook login found"}
@app.post("/agent/run")
async def agent_run(task: str = Body(..., embed=True)):
"""Run a browser-use Agent with ChatOllama (free/local)."""
try:
browser = _make_browser(headless=True, args=CHROME_LAUNCH_ARGS)
await browser.start()
agent = _make_agent(
task=task,
llm=_make_ollama(num_ctx=32000, temperature=0.3),
browser=browser,
use_vision=False,
max_actions_per_step=5,
max_failures=8,
step_timeout=180,
)
history = await agent.run()
await browser.close()
return {"success": True, "result": history.final_result()}
except Exception as e:
logger.error("Agent run failed: %s", e)
return {"success": False, "error": str(e)}
@app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False), query: str | None = Query(None)):
result = await scrape_facebook(profile_path, force, query)
return result
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT)