Merge origin/main into main
Build & Auto-Repair / build (push) Has been cancelled

This commit is contained in:
2026-07-01 12:40:53 +02:00
5 changed files with 400 additions and 103 deletions
+312 -74
View File
@@ -341,6 +341,7 @@ OFFER_PATTERNS = [
r"\bfree\s+domain\b", r"\bfree\s+domain\b",
r"\bweb\s+hosting\b", r"\bweb\s+hosting\b",
r"\bfree\s+website\b", r"\bfree\s+website\b",
r"\bfree\s+\w+\s+website\b",
r"\bprofessional\s+website", r"\bprofessional\s+website",
r"\baffordable\s+web\b", r"\baffordable\s+web\b",
r"\bget\s+a\s+free\b", r"\bget\s+a\s+free\b",
@@ -365,6 +366,47 @@ OFFER_PATTERNS = [
r"\bsponsored\b", r"\bsponsored\b",
r"\bpromoted\b", r"\bpromoted\b",
r"\badvertisement\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"\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",
] ]
REQUEST_PATTERNS = [ REQUEST_PATTERNS = [
@@ -549,6 +591,48 @@ FB_SEARCHES = [
"want to build a website for my", "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
VIEWPORTS = [ VIEWPORTS = [
{'width': 1280, 'height': 800}, {'width': 1280, 'height': 800},
{'width': 1366, 'height': 768}, {'width': 1366, 'height': 768},
@@ -663,6 +747,9 @@ def _clean_fb_text(text: str) -> str:
cleaned_lines.append(stripped) cleaned_lines.append(stripped)
return '\n'.join(cleaned_lines) 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"}
# ── Post Extraction ────────────────────────────────────────────────── # ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content: # Two strategies for extracting posts from page content:
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements() # 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
@@ -720,6 +807,25 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts = [] posts = []
seen_texts = set() seen_texts = set()
cur = [] 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: for l in lines:
words = re.findall(r'[A-Za-z]{2,}', l) words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) < 2: if len(words) < 2:
@@ -735,7 +841,7 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
posts.append({ posts.append({
"title": snippet[:300], "title": snippet[:300],
"content": snippet[:1000], "content": snippet[:1000],
"author": '', "author": _find_author(cur[-3:]),
"url": url, "url": url,
"source": "facebook", "source": "facebook",
"date": _parse_fb_date(cur + [l]), "date": _parse_fb_date(cur + [l]),
@@ -759,7 +865,40 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
cur.append(l) cur.append(l)
if len(cur) > 10: if len(cur) > 10:
cur.pop(0) cur.pop(0)
return posts # 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
_group_suffixes = [" - South Africa", " - Australia", " PH", "Group", "help group", "info 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)
return filtered
# ── Human-like Behavior Simulation ────────────────────────────────── # ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns # These functions add random delays, mouse movements, and scroll patterns
@@ -811,58 +950,31 @@ async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => { return await page.evaluate('''() => {
const results = []; const results = [];
const seenTexts = new Set(); const seenTexts = new Set();
const selectors = [ const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce"];
'div[role="article"]', const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
'div[role="feed"] > div', for (const a of anchors) {
'div.x1yztbdb', const h = a.getAttribute('href') || '';
'div[data-pagelet]', if (!h) continue;
]; const cell = a.closest('div[style]') || a.parentElement;
for (const sel of selectors) { if (!cell) continue;
const els = document.querySelectorAll(sel); const txt = (cell.innerText || '').trim();
for (const el of els) { if (txt.length < 40) continue;
const text = (el.innerText || '').trim(); const lower = txt.toLowerCase();
if (text.length < 40) continue; let matched = false;
const key = text.substring(0, 80); 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; if (seenTexts.has(key)) continue;
seenTexts.add(key); seenTexts.add(key);
// --- Publisher name ---
let author = ''; let author = '';
const nameEl = el.querySelector('h4, strong, a[role="link"]'); const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) { if (nameEl) author = (nameEl.innerText || '').trim();
author = (nameEl.innerText || '').trim(); if (!author || author.length > 60 || author.length < 2) author = '';
} let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
if (!author) {
const links = el.querySelectorAll('a');
for (const a of links) {
const t = (a.innerText || '').trim();
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
author = t;
break;
}
}
}
// --- Post URL ---
let postUrl = '';
for (const a of el.querySelectorAll('a')) {
const h = (a.getAttribute('href') || '');
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
break;
}
}
// --- Date from <time datetime> ---
let date = ''; let date = '';
const timeEl = el.querySelector('time'); const timeEl = cell.querySelector('time');
if (timeEl) { if (timeEl) date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || ''; results.push({ text: txt, author, url: postUrl, date });
}
results.push({ text, author, url: postUrl, date });
}
if (results.length > 0) break;
} }
return results; return results;
}''') }''')
@@ -896,15 +1008,11 @@ async def search_facebook(page, context, query: str):
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}' url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try: try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000) await page.goto(url, wait_until='domcontentloaded', timeout=30000)
try: await page.wait_for_timeout(random.randint(5000, 10000))
await page.wait_for_selector('div[role="article"], div[role="feed"]', timeout=15000)
except Exception:
pass
await page.wait_for_timeout(random.randint(3000, 8000))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15)) await human_scroll(page, steps=random.randint(4, 7), total_delay=random.uniform(12, 25))
if random.random() < 0.1: if random.random() < 0.3:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(random.randint(1000, 3000)) await page.wait_for_timeout(random.randint(1000, 3000))
await page.evaluate("window.scrollBy(0, -300)") await page.evaluate("window.scrollBy(0, -300)")
@@ -919,9 +1027,100 @@ async def search_facebook(page, context, query: str):
raw_articles = await _get_article_elements(page) raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else [] posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
if not posts:
raw = await page.evaluate('document.body.innerText') raw = await page.evaluate('document.body.innerText')
posts = _extract_posts_from_text(raw, url) 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 ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e: except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e) logger.warning("Facebook search '%s' failed: %s", query, e)
return page, [] return page, []
@@ -971,7 +1170,7 @@ def cleanup_chrome():
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins) # - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
# - If no profile found → Agent (browser-use + ChatOllama) fallback # - If no profile found → Agent (browser-use + ChatOllama) fallback
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict: 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.""" """Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
effective_path = profile_path or "" effective_path = profile_path or ""
browser_type = "" browser_type = ""
@@ -1013,14 +1212,14 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# Firefox path # Firefox path
if browser_type == "firefox": if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force) result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"): if result.get("success") or not result.get("flagged"):
return result return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown")) logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force) return await _scrape_with_agent(force)
# Chromium-based (chrome / opera / edge) # Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force) result = await _scrape_with_chromium(effective_path, browser_type, force, query)
if result.get("success") or not result.get("flagged"): if result.get("success") or not result.get("flagged"):
return result return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown")) logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
@@ -1035,7 +1234,7 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
# human browsing). If flagged by Facebook, returns flagged=True for the # human browsing). If flagged by Facebook, returns flagged=True for the
# dispatcher to fall through to the Agent. # dispatcher to fall through to the Agent.
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict: 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).""" """Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
if not profile_path: if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"} return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
@@ -1089,9 +1288,13 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None} return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = [] all_posts = []
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4)) searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches): for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, query) page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts) all_posts.extend(posts)
if not posts: if not posts:
continue continue
@@ -1142,6 +1345,12 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
pass pass
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ─────────────────────────────────────────────────
# ── Chromium Scraper ───────────────────────────────────────────────── # ── Chromium Scraper ─────────────────────────────────────────────────
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the # Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
# Firefox scraper but with Chromium-specific launch config: # Firefox scraper but with Chromium-specific launch config:
@@ -1152,7 +1361,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
# Anti-detection script differs slightly from Firefox variant. # Anti-detection script differs slightly from Firefox variant.
# Same decoy-skip and flagged-fallback behavior as Firefox. # Same decoy-skip and flagged-fallback behavior as Firefox.
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False) -> dict: 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).""" """Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
if not profile_path: if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"} return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
@@ -1214,9 +1423,13 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None} return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = [] all_posts = []
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4)) searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches): for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, query) page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts) all_posts.extend(posts)
if not posts: if not posts:
continue continue
@@ -1449,15 +1662,35 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
"price for", "price for",
] ]
keyword_leads: list[dict] = [] 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', 'homeschool', 'your home tutor',
'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', 'australia web designers',
'south africa', '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',
]
for r in results: for r in results:
t = r['title'].lower() t = r['title'].lower()
has_web = any(kw in t for kw in web_terms) has_web = any(kw in t for kw in web_terms)
has_request = any(kw in t for kw in request_terms) has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request: if not has_web or not has_request:
continue continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer', if any(kw in t for kw in offer_reject):
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
continue continue
keyword_leads.append(r) keyword_leads.append(r)
@@ -1469,6 +1702,8 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
if key not in seen_titles: if key not in seen_titles:
seen_titles.add(key) seen_titles.add(key)
merged.append(r) merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
# Fill to 5 with loose keyword matches (at least web OR request term) # Fill to 5 with loose keyword matches (at least web OR request term)
if len(merged) < 5: if len(merged) < 5:
@@ -1477,7 +1712,10 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
if key in seen_titles: if key in seen_titles:
continue continue
t = r['title'].lower() t = r['title'].lower()
if any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms): if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
continue
if any(kw in t for kw in offer_reject):
continue
seen_titles.add(key) seen_titles.add(key)
merged.append(r) merged.append(r)
if len(merged) >= 5: if len(merged) >= 5:
@@ -1601,8 +1839,8 @@ async def agent_run(task: str = Body(..., embed=True)):
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@app.post("/scrape/facebook") @app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)): 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) result = await scrape_facebook(profile_path, force, query)
return result return result
if __name__ == "__main__": if __name__ == "__main__":
+2 -10
View File
@@ -1,10 +1,2 @@
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"} {"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"} {"job_title":"Tutoring","keywords":["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"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
+42 -4
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useCallback, useRef, useEffect } from "react" import { useState, useCallback, useRef } from "react"
import { AIChat } from "@/components/ai/ai-chat" import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector" import { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight } from "lucide-react" import { Bot, ChevronRight } from "lucide-react"
@@ -16,12 +16,50 @@ const tips = [
export default function AIAssistantPage() { export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([]) const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null) const [searching, setSearching] = useState(false)
const aiChatRef = useRef<AIChatHandle | null>(null)
const handleJobSelect = useCallback((job: typeof selectedJob) => { const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job) setSelectedJob(job)
}, []) }, [])
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const keyword = job.keywords[0]
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 360000)
const statusId = setTimeout(() => {
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
}, 45000)
try {
const res = await fetch(`http://localhost:3008/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
clearTimeout(timeoutId)
clearTimeout(statusId)
const data = await res.json()
if (data.success && data.leads?.length > 0) {
const leadsText = data.leads.map((lead: any, i: number) =>
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}`
).join("\n\n")
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
} else {
const reason = data.error || data.flag_reason || "No leads found this time"
aiChatRef.current?.addAssistantMessage(`⚠️ ${reason}`)
}
} catch (err: any) {
clearTimeout(timeoutId)
clearTimeout(statusId)
const msg = err.name === "AbortError"
? "❌ Scraper timed out after 5 minutes. Try again or check the scraper service."
: `${err instanceof Error ? err.message : "Search failed"}`
aiChatRef.current?.addAssistantMessage(msg)
} finally {
setSearching(false)
}
}, [])
const handleTipClick = useCallback((tip: string) => { const handleTipClick = useCallback((tip: string) => {
aiChatRef.current?.fillInput(tip) aiChatRef.current?.fillInput(tip)
}, []) }, [])
@@ -71,7 +109,7 @@ export default function AIAssistantPage() {
<span className="w-1.5 h-1.5 rounded-full bg-primary" /> <span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span> <span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div> </div>
<JobSelector onSelect={handleJobSelect} /> <JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{selectedJob && ( {selectedJob && (
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2"> <div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4> <h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
+17 -1
View File
@@ -48,7 +48,12 @@ interface AIChatProps {
} }
export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => { export interface AIChatHandle {
fillInput: (text: string) => void
addAssistantMessage: (content: string) => void
}
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -66,6 +71,17 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr
setInput(text) setInput(text)
setTimeout(() => textareaRef.current?.focus(), 50) setTimeout(() => textareaRef.current?.focus(), 50)
}, },
addAssistantMessage(content: string) {
setMessages((prev) => {
if (!prev.some((m) => m.role === "user")) {
return [
{ role: "user", content: "Search Facebook" },
{ role: "assistant", content },
]
}
return [...prev, { role: "assistant", content }]
})
},
}), []) }), [])
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => { const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
+16 -3
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2 } from "lucide-react" import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
interface Job { interface Job {
job_title: string job_title: string
@@ -12,9 +12,11 @@ interface Job {
interface JobSelectorProps { interface JobSelectorProps {
onSelect: (job: Job | null) => void onSelect: (job: Job | null) => void
onSearch?: (job: Job) => void
searching?: boolean
} }
export function JobSelector({ onSelect }: JobSelectorProps) { export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -41,7 +43,7 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200" className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200"
> >
<Briefcase className="h-4 w-4 text-primary flex-none" /> <Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
<span className="flex-1 text-left truncate"> <span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"} {selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span> </span>
@@ -69,6 +71,17 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
</div> </div>
</> </>
)} )}
{selected && onSearch && (
<button
type="button"
onClick={() => onSearch(selected)}
disabled={searching}
className="w-full flex items-center justify-center gap-2 mt-3 bg-gradient-to-r from-[#f97316] to-[#ea580c] hover:from-[#ea580c] hover:to-[#dc2626] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-[#f97316]/20"
>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Facebook"}
</button>
)}
</div> </div>
) )
} }