Compare commits
12 Commits
main
...
9df78f7d10
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df78f7d10 | |||
| 4914acf085 | |||
| 828bdd2de6 | |||
| f45f9d61da | |||
| 1bd1eed346 | |||
| 9556b67656 | |||
| b59cc65508 | |||
| f503bf98f8 | |||
| 6217634c41 | |||
| 2e652266b6 | |||
| 96a4323a41 | |||
| 0d61c9cff2 |
@@ -40,3 +40,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.git
|
||||
.git_bak
|
||||
node_modules
|
||||
target
|
||||
Binary file not shown.
@@ -0,0 +1,353 @@
|
||||
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from bs4 import BeautifulSoup
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
import uvicorn
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
app = FastAPI()
|
||||
PORT = int(os.getenv("PORT", "3008"))
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
||||
|
||||
FX_PROFILE = os.getenv('FX_PROFILE', r'C:\Users\USER-PC\AppData\Roaming\Mozilla\Firefox\Profiles\h8p11vlj.default-release')
|
||||
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite')
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
}
|
||||
|
||||
last_scrape_time: datetime | None = None
|
||||
_pw_browser = None
|
||||
|
||||
STRICT_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",
|
||||
]
|
||||
|
||||
BROAD_KEYWORDS = STRICT_KEYWORDS + [
|
||||
"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",
|
||||
]
|
||||
|
||||
def kw_match_strict(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return any(kw in t for kw in STRICT_KEYWORDS)
|
||||
|
||||
def kw_match(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||
|
||||
FB_SEARCHES = [
|
||||
"looking for web developer",
|
||||
"need a website designed",
|
||||
"need website built South Africa",
|
||||
"need someone to build my website",
|
||||
"need web designer",
|
||||
"looking for someone to create website",
|
||||
"need ecommerce website built",
|
||||
"want to hire web developer",
|
||||
"website quote please",
|
||||
"need wordpress website",
|
||||
"I need a website for my business",
|
||||
"need a site for my business",
|
||||
]
|
||||
|
||||
def get_fb_cookies():
|
||||
"""Copy Firefox cookie DB and extract Facebook cookies for Chromium injection."""
|
||||
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), 'cookies_tmp.sqlite')
|
||||
try:
|
||||
if not os.path.exists(FX_COOKIE_DB):
|
||||
return []
|
||||
shutil.copy2(FX_COOKIE_DB, tmp)
|
||||
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()
|
||||
os.remove(tmp)
|
||||
return [{
|
||||
"name": name, "value": value,
|
||||
"domain": host if host.startswith('.') else f'.{host}',
|
||||
"path": path,
|
||||
"httpOnly": False, "secure": False, "sameSite": "Lax",
|
||||
} for name, value, host, path in rows]
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def _is_real_text(line: str) -> bool:
|
||||
if not line:
|
||||
return False
|
||||
# Count words (sequences of letters)
|
||||
words = re.findall(r'[A-Za-z]{2,}', line)
|
||||
return len(words) >= 2
|
||||
|
||||
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
||||
"""Parse Facebook search results page text into structured posts."""
|
||||
lines = [l.strip() for l in raw.split('\n')]
|
||||
|
||||
# Split into blocks separated by "Facebook" boilerplate lines (2+ consecutive)
|
||||
blocks = []
|
||||
cur = []
|
||||
fb_run = 0
|
||||
for l in lines:
|
||||
if 'facebook' in l.lower():
|
||||
fb_run += 1
|
||||
if cur and fb_run >= 2:
|
||||
blocks.append(cur)
|
||||
cur = []
|
||||
else:
|
||||
fb_run = 0
|
||||
if l and len(l) >= 15:
|
||||
# Check for real words (not scattered reaction chars)
|
||||
words = re.findall(r'[A-Za-z]{2,}', l)
|
||||
if len(words) >= 2:
|
||||
cur.append(l)
|
||||
if cur:
|
||||
blocks.append(cur)
|
||||
|
||||
posts = []
|
||||
seen_texts = set()
|
||||
for block in blocks:
|
||||
if not block:
|
||||
continue
|
||||
# Find keyword-matched content in this block
|
||||
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
|
||||
if not kw_indices:
|
||||
continue
|
||||
# Use first keyword match as anchor, grab context
|
||||
i = kw_indices[0]
|
||||
start = max(0, i - 2)
|
||||
end = min(len(block), i + 5)
|
||||
snippet = ' '.join(block[start:end])
|
||||
if len(snippet) < 40:
|
||||
continue
|
||||
dekey = snippet[:80]
|
||||
if dekey in seen_texts:
|
||||
continue
|
||||
seen_texts.add(dekey)
|
||||
posts.append({
|
||||
"title": snippet[:300],
|
||||
"content": snippet[:1000],
|
||||
"author": block[start] if start < i else '',
|
||||
"url": url,
|
||||
"source": "facebook",
|
||||
"date": datetime.now().strftime('%Y-%m-%d'),
|
||||
})
|
||||
|
||||
return posts
|
||||
|
||||
async def search_facebook(page, query: str) -> list[dict]:
|
||||
"""Search Facebook and extract post leads from raw page text."""
|
||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||
try:
|
||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||
# Wait for the page content to fully render (React/XHR loads)
|
||||
await page.wait_for_timeout(6000)
|
||||
except:
|
||||
return []
|
||||
|
||||
raw = await page.evaluate('document.body.innerText')
|
||||
return _extract_posts_from_text(raw, url)
|
||||
|
||||
async def scrape_facebook() -> list[dict]:
|
||||
"""Launch headless Chromium, inject Firefox Facebook cookies, search for leads."""
|
||||
all_posts = []
|
||||
fb_cookies = get_fb_cookies()
|
||||
if not fb_cookies:
|
||||
return []
|
||||
try:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=True)
|
||||
context = await browser.new_context(
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
||||
viewport={'width': 1280, 'height': 800},
|
||||
)
|
||||
for c in fb_cookies:
|
||||
try:
|
||||
await context.add_cookies([c])
|
||||
except:
|
||||
pass
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
url = page.url
|
||||
if '/login' in url.lower():
|
||||
return []
|
||||
|
||||
for query in FB_SEARCHES[:6]:
|
||||
try:
|
||||
posts = await search_facebook(page, query)
|
||||
all_posts.extend(posts)
|
||||
await page.wait_for_timeout(2000)
|
||||
except:
|
||||
continue
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
seen = set()
|
||||
deduped = []
|
||||
for p in all_posts:
|
||||
key = p['content'][:100]
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(p)
|
||||
return deduped[:20]
|
||||
|
||||
async def ask_ollama(prompt: str) -> str:
|
||||
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()
|
||||
return r.json()["message"]["content"]
|
||||
|
||||
async def scrape_warriorforum() -> list[dict]:
|
||||
results = []
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=HEADERS, timeout=15, follow_redirects=True) as c:
|
||||
r = await c.get('https://www.warriorforum.com/wanted-members-looking-hire-you/')
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
for row in soup.find_all('td', class_='FlexTable-item--title'):
|
||||
a = row.find('a', href=True)
|
||||
if not a:
|
||||
continue
|
||||
href = a['href']
|
||||
title = a.text.strip()
|
||||
if not title or len(title) < 15 or not kw_match_strict(title):
|
||||
continue
|
||||
author_div = row.find('div', class_='FlexTable-item-author')
|
||||
author = author_div.get_text(strip=True).lstrip('by') if author_div else ''
|
||||
date_str = ''
|
||||
date_div = row.find('div', class_=lambda c: c and 'media--available' in c)
|
||||
if date_div:
|
||||
txt = date_div.get_text(strip=True)
|
||||
m = re.search(r'(\d{10})', txt)
|
||||
if m:
|
||||
from datetime import datetime as dtdt
|
||||
date_str = dtdt.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
|
||||
results.append({
|
||||
"title": title, "url": href,
|
||||
"author": author,
|
||||
"content": title[:300],
|
||||
"source": "warriorforum",
|
||||
"date": date_str
|
||||
})
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return results
|
||||
|
||||
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||
if not results:
|
||||
return []
|
||||
filtered = []
|
||||
briefs = [r["title"][:200] for r in results]
|
||||
prompt = f"""Classify each post as LEAD or NOT.
|
||||
LEAD = 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:
|
||||
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
|
||||
- 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
|
||||
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
||||
|
||||
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."""
|
||||
try:
|
||||
raw = await ask_ollama(prompt)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```json"): raw = raw[7:]
|
||||
if raw.startswith("```"): raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0]
|
||||
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':
|
||||
filtered.append(results[i])
|
||||
except:
|
||||
pass
|
||||
if not filtered:
|
||||
for r in results:
|
||||
t = r['title'].lower()
|
||||
# MUST match a web-dev keyword to be a lead
|
||||
if not kw_match_strict(t):
|
||||
continue
|
||||
# But NOT these (offerings, not requests)
|
||||
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
||||
'affordable web', 'web hosting', 'free website',
|
||||
'limited time', 'special offer', 'sign up now',
|
||||
'offer']):
|
||||
continue
|
||||
filtered.append(r)
|
||||
return filtered[:10]
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
class ScrapeRequest(BaseModel):
|
||||
query: str = ""
|
||||
|
||||
@app.post("/scrape/requests")
|
||||
async def scrape_requests(req: ScrapeRequest):
|
||||
global last_scrape_time
|
||||
now = datetime.now()
|
||||
if last_scrape_time and (now - last_scrape_time) < timedelta(seconds=15):
|
||||
return {"error": "rate_limited", "retry_after": 15}
|
||||
last_scrape_time = now
|
||||
results = await scrape_warriorforum()
|
||||
if results:
|
||||
results = await classify_leads(results)
|
||||
return results[:10]
|
||||
|
||||
@app.post("/scrape/facebook")
|
||||
async def scrape_facebook_endpoint():
|
||||
results = await scrape_facebook()
|
||||
if results:
|
||||
results = await classify_leads(results)
|
||||
return results[:15]
|
||||
|
||||
@app.post("/scrape/all")
|
||||
async def scrape_all():
|
||||
results = []
|
||||
try:
|
||||
wf = await scrape_warriorforum()
|
||||
if wf:
|
||||
wf = await classify_leads(wf)
|
||||
results.extend(wf[:5])
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
fb = await scrape_facebook()
|
||||
if fb:
|
||||
fb = await classify_leads(fb)
|
||||
results.extend(fb[:8])
|
||||
except:
|
||||
pass
|
||||
return results[:12]
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||
@@ -0,0 +1,7 @@
|
||||
python : INFO: Started server process [20044]
|
||||
+ CategoryInfo : NotSpecified: (INFO: Started server process [20044]:String) [], RemoteException
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
||||
@@ -0,0 +1,6 @@
|
||||
[2026-06-22T19:24:48.030482] Browser Use service starting on port 3008
|
||||
[2026-06-22T19:27:19.231701] Browser Use service starting on port 3008
|
||||
[2026-06-22T19:28:28.335765] Browser Use service starting on port 3008
|
||||
[2026-06-22T19:29:05.796265] Browser Use service starting on port 3008
|
||||
[2026-06-22T19:29:17.042807] Scraping WarriorForum...
|
||||
[2026-06-22T19:29:19.075166] WarriorForum: 55 results
|
||||
@@ -0,0 +1,4 @@
|
||||
INFO: Started server process [24268]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
||||
@@ -0,0 +1,5 @@
|
||||
Browser Use service starting on port 3008
|
||||
INFO: 127.0.0.1:65003 - "GET /health HTTP/1.1" 200 OK
|
||||
Scraping WarriorForum...
|
||||
WarriorForum: 55 results
|
||||
INFO: 127.0.0.1:54587 - "POST /scrape/all HTTP/1.1" 200 OK
|
||||
@@ -0,0 +1,40 @@
|
||||
# AI Sales Assistant — Self-Improvement Instructions
|
||||
|
||||
## Purpose
|
||||
This file contains the AI's own configuration, knowledge, and improvement rules.
|
||||
The AI can read and modify this file to update its behavior at runtime.
|
||||
|
||||
## Current Instructions
|
||||
- Always respond in English
|
||||
- Keep responses under 300 words unless asked for detail
|
||||
- Use bullet points for lists
|
||||
- Be direct and actionable — no fluff
|
||||
- Never mention being an AI or language model
|
||||
- Refer to the user by their role (salesperson, admin, etc.)
|
||||
- If unsure about a topic, say "I don't have that information yet" rather than guessing
|
||||
|
||||
## Knowledge Base
|
||||
### Sales Tips
|
||||
- Cold emails should be under 150 words
|
||||
- Follow up within 48 hours
|
||||
- Personalise every outreach with the prospect's name and company
|
||||
- Use open-ended questions in discovery calls
|
||||
- Always ask for the next step before ending a call
|
||||
|
||||
### Job Targeting
|
||||
- Developers respond best to technical value props
|
||||
- Marketing managers care about ROI and metrics
|
||||
- C-level executives want brevity and business impact
|
||||
|
||||
## Improvement Log
|
||||
Track changes made by the AI to improve itself:
|
||||
- (initial) Basic instructions and knowledge base created
|
||||
|
||||
## Self-Modification Rules
|
||||
The AI may update this file when:
|
||||
1. It identifies a gap in its knowledge that would help salespeople
|
||||
2. It discovers a better way to structure responses
|
||||
3. A user explicitly requests an update to behavior
|
||||
4. It notices repeated questions that aren't well-covered
|
||||
|
||||
Only append to the Improvement Log — don't delete previous entries.
|
||||
@@ -0,0 +1,10 @@
|
||||
{"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":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
|
||||
{"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"}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AI Sales Assistant tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'sales',
|
||||
message TEXT NOT NULL,
|
||||
response TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_created ON ai_conversations(created_at DESC);
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
link TEXT,
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
lead_assigned BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lead_status BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note_added BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
daily_digest BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
weekly_report BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS company_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||
company_website VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_address TEXT NOT NULL DEFAULT '',
|
||||
updated_by UUID REFERENCES users(id),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
|
||||
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
|
||||
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
|
||||
items_per_page INTEGER NOT NULL DEFAULT 20,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -10,6 +10,27 @@
|
||||
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
||||
\i 002_seed.sql
|
||||
|
||||
\echo '=== Running 003_chat.sql (Chat Tables) ==='
|
||||
\i 003_chat.sql
|
||||
|
||||
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
|
||||
\i 004_avatar_url.sql
|
||||
|
||||
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
|
||||
\i 005_last_read_at.sql
|
||||
|
||||
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
|
||||
\i 006_test_leads.sql
|
||||
|
||||
\echo '=== Running 007_ai_features.sql (AI Features) ==='
|
||||
\i 007_ai_features.sql
|
||||
|
||||
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
|
||||
\i 008_notifications.sql
|
||||
|
||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||
\i 009_settings.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
\echo ''
|
||||
\echo 'Test accounts created:'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Scrapers - Configuration & Status
|
||||
|
||||
## Facebook Scraper
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~70-85
|
||||
|
||||
Target URL (line 72):
|
||||
```rust
|
||||
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
|
||||
```
|
||||
|
||||
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
|
||||
|
||||
**Test with curl:**
|
||||
```
|
||||
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
|
||||
```
|
||||
|
||||
**Expected log output when blocked:**
|
||||
```
|
||||
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
|
||||
```
|
||||
|
||||
## Reddit Scraper
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~90-120
|
||||
|
||||
Uses `old.reddit.com` (older design that allows scraping without captchas).
|
||||
|
||||
Search queries (currently 6, lines 93-100):
|
||||
- `r/southafrica` — "need website", "web developer"
|
||||
- Global — '"need a website"', "website quote"
|
||||
- `r/forhire` — "website"
|
||||
- `r/smallbusiness` — "website"
|
||||
|
||||
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
|
||||
|
||||
**Test with curl:**
|
||||
```
|
||||
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
```
|
||||
|
||||
**Expected log output when working:**
|
||||
```
|
||||
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
|
||||
```
|
||||
|
||||
## Background Loop
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~370-383
|
||||
|
||||
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Facebook
|
||||
- Try running from your home PC instead — your residential IP may not be blocked
|
||||
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
|
||||
- Configure them in the `PROXIES` array at line 25
|
||||
|
||||
### Reddit
|
||||
- Works out of the box via `old.reddit.com`
|
||||
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
|
||||
|
||||
### To add more search queries
|
||||
Edit the `searches` array in `run_reddit_scraper()` (line 93).
|
||||
|
||||
|
||||
Generated
+245
@@ -51,6 +51,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -2644,6 +2645,19 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -3199,6 +3213,21 @@
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -3263,6 +3292,56 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4420,6 +4499,29 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -6491,6 +6593,16 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
||||
@@ -6669,6 +6781,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
@@ -6802,6 +6927,31 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -6910,6 +7060,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -7177,6 +7343,16 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -7599,6 +7775,37 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@@ -7607,6 +7814,44 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+9
-2
@@ -3,9 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3006",
|
||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "powershell -NoProfile -Command \"$targetPorts=3001,3006,3008; netstat -ano | Select-String LISTENING | ForEach-Object { $line=$_.ToString(); foreach($p in $targetPorts){ if($line -match ('[:]'+$p+'\\s')){ $foundPid=($line -split '\\s+')[-1]; try{ Stop-Process -Id $foundPid -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$p) }catch{} } } }; exit 0\"",
|
||||
"dev:ollama": "powershell -NoProfile -Command \"if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||
"dev:rust": "cd rust-ai && cargo run",
|
||||
"dev:browser-use": "cd browser-use-service && python main.py",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "npm run dev:next",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -52,6 +58,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"postcss": "^8.4.49",
|
||||
|
||||
Generated
+3032
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "crm-ai"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "AI Sales Assistant backend for Coast IT CRM"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
dotenvy = "0.15"
|
||||
scraper = "0.12"
|
||||
rand = "0.8"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CRM AI Service — Self-Knowledge
|
||||
|
||||
## Identity
|
||||
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
|
||||
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
|
||||
Your purpose is to help salespeople close more deals.
|
||||
|
||||
## Capabilities
|
||||
- Give sales tips and strategies per job category
|
||||
- Generate cold email and outreach templates
|
||||
- Handle objections with proven rebuttals
|
||||
- Analyse prospect behaviour and suggest next steps
|
||||
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||
|
||||
## Architecture
|
||||
```
|
||||
User → Next.js → Rust (:3001) → Ollama (:11434)
|
||||
↓
|
||||
PostgreSQL
|
||||
```
|
||||
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
|
||||
|
||||
## Facebook Scraper (in code but not yet active)
|
||||
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
|
||||
To activate: call `run_facebook_scraper()` from the main loop.
|
||||
Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||
|
||||
## Self-Improvement Protocol
|
||||
1. You notice a gap in your knowledge or a pattern in user questions
|
||||
2. You call `POST /ai/instructions` with:
|
||||
- `entry`: description of the improvement
|
||||
- `content`: optional full replacement of ai.md
|
||||
3. The improvement is logged and loaded into the next system prompt
|
||||
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
|
||||
|
||||
## Response Rules
|
||||
- Be direct and actionable — no fluff, no AI disclaimers
|
||||
- Use short paragraphs and bullet points
|
||||
- Never mention being an AI or language model
|
||||
- If you don't know something, say so honestly
|
||||
- Prioritise the user's role: salespeople need speed, admins need control
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Manages the ai.md self-improvement file.
|
||||
/// Loaded on startup and periodically refreshed.
|
||||
pub struct InstructionsManager {
|
||||
path: PathBuf,
|
||||
current: RwLock<String>,
|
||||
}
|
||||
|
||||
impl InstructionsManager {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
let initial = fs::read_to_string(&path).unwrap_or_default();
|
||||
if initial.is_empty() {
|
||||
warn!("ai.md is empty or missing at {:?}", path);
|
||||
} else {
|
||||
info!("Loaded ai.md ({} bytes)", initial.len());
|
||||
}
|
||||
Self {
|
||||
path,
|
||||
current: RwLock::new(initial),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current instructions
|
||||
pub async fn get(&self) -> String {
|
||||
self.current.read().await.clone()
|
||||
}
|
||||
|
||||
/// Append a new entry to the Improvement Log and optionally update the instructions.
|
||||
/// Returns the updated full content.
|
||||
pub async fn update(&self, entry: &str, new_content: Option<&str>) -> Result<String, String> {
|
||||
if let Some(content) = new_content {
|
||||
// Full replacement
|
||||
fs::write(&self.path, content).map_err(|e| format!("Write failed: {}", e))?;
|
||||
let mut current = self.current.write().await;
|
||||
*current = content.to_string();
|
||||
info!("ai.md fully replaced ({} bytes)", content.len());
|
||||
Ok(content.to_string())
|
||||
} else {
|
||||
// Append to Improvement Log only
|
||||
let mut current = self.current.write().await;
|
||||
let log_entry = format!("\n- {} — {}", chrono::Utc::now().format("%Y-%m-%d %H:%M"), entry);
|
||||
// Find the Improvement Log section and append
|
||||
if let Some(pos) = current.rfind("\n## Improvement Log") {
|
||||
// Find the next section after Improvement Log, or end of file
|
||||
let after_log = ¤t[pos..];
|
||||
if let Some(section_start) = after_log[1..].find("\n## ") {
|
||||
let insert_at = pos + 1 + section_start;
|
||||
current.insert_str(insert_at, &format!("{}\n", log_entry));
|
||||
} else {
|
||||
current.push_str(&format!("{}\n", log_entry));
|
||||
}
|
||||
} else {
|
||||
current.push_str(&format!("\n## Improvement Log\n{}", log_entry));
|
||||
}
|
||||
fs::write(&self.path, &*current).map_err(|e| format!("Write failed: {}", e))?;
|
||||
info!("ai.md improvement log appended: {}", entry);
|
||||
Ok(current.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload from disk
|
||||
pub async fn reload(&self) {
|
||||
match fs::read_to_string(&self.path) {
|
||||
Ok(content) => {
|
||||
let len = content.len();
|
||||
let mut current = self.current.write().await;
|
||||
*current = content;
|
||||
info!("ai.md reloaded from disk ({} bytes)", len);
|
||||
}
|
||||
Err(e) => error!("Failed to reload ai.md: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for thread-safe sharing
|
||||
pub type SharedInstructions = Arc<InstructionsManager>;
|
||||
|
||||
pub fn create_shared(path: PathBuf) -> SharedInstructions {
|
||||
Arc::new(InstructionsManager::new(path))
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::Method,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
use rand::Rng;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── Shared state ───────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
db: sqlx::PgPool,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
jobs: Vec<Job>,
|
||||
leads: Arc<Mutex<LeadStore>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct Lead {
|
||||
title: String,
|
||||
url: String,
|
||||
source: String,
|
||||
found_at: u64,
|
||||
author: String,
|
||||
date: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
struct LeadStore {
|
||||
leads: Vec<Lead>,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
impl LeadStore {
|
||||
fn new(max_size: usize) -> Self {
|
||||
Self { leads: Vec::new(), max_size }
|
||||
}
|
||||
|
||||
fn push(&mut self, lead: Lead) {
|
||||
// Deduplicate by URL
|
||||
if !self.leads.iter().any(|l| l.url == lead.url) {
|
||||
self.leads.insert(0, lead);
|
||||
self.leads.truncate(self.max_size);
|
||||
}
|
||||
}
|
||||
|
||||
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
self.leads.iter()
|
||||
.filter(|l| now - l.found_at <= max_age_secs)
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Job {
|
||||
job_title: String,
|
||||
keywords: Vec<String>,
|
||||
industry: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatRequest {
|
||||
message: String,
|
||||
user_id: String,
|
||||
user_role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JobsResponse {
|
||||
jobs: Vec<Job>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
// ── Ollama API types ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaChatMessage>,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f32,
|
||||
num_predict: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponse {
|
||||
message: Option<OllamaResponseMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
// ── System prompt builder ─────────────────────────────────────
|
||||
|
||||
fn format_leads_output(leads: &[Lead]) -> String {
|
||||
if leads.is_empty() {
|
||||
return "No new requests found yet.".to_string();
|
||||
}
|
||||
leads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
|
||||
let date = if l.date.is_empty() { "Unknown" } else { &l.date[..l.date.len().min(10)] };
|
||||
format!(
|
||||
"{}. {}\n {}\n {}\n {}",
|
||||
i + 1,
|
||||
author,
|
||||
date,
|
||||
l.title,
|
||||
l.url
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
|
||||
let job_list: Vec<String> = jobs
|
||||
.iter()
|
||||
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
|
||||
.collect();
|
||||
let job_list_str = job_list.join("\n");
|
||||
|
||||
let lead_summary: Vec<String> = leads
|
||||
.iter()
|
||||
.map(|l| {
|
||||
format!("{} | {} | {}", l.author, l.title, l.url)
|
||||
})
|
||||
.collect();
|
||||
let lead_summary_str = if lead_summary.is_empty() {
|
||||
"None yet.".to_string()
|
||||
} else {
|
||||
lead_summary.join("\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
|
||||
Available job categories to target:\n{}\n\n\
|
||||
Recent leads context:\n{}\n\n\
|
||||
Rules:\n\
|
||||
- When asked about leads, answer concisely under 150 words.\n\
|
||||
- If asked to suggest a sales strategy, give brief actionable advice.\n\
|
||||
- Be direct and professional. No fluff.",
|
||||
job_list_str, lead_summary_str
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chat handler ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, (axum::http::StatusCode, String)> {
|
||||
// Validate role
|
||||
match req.user_role.as_str() {
|
||||
"sales" | "admin" | "super_admin" => {}
|
||||
_ => return Err((axum::http::StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||
}
|
||||
|
||||
// Intercept lead listing requests — scrape on demand then return
|
||||
let msg_lower = req.message.to_lowercase();
|
||||
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
|
||||
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
|
||||
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
|
||||
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
||||
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
||||
// Scrape fresh leads now — call both services
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
let client = reqwest::Client::new();
|
||||
let services = [
|
||||
"http://localhost:3008/scrape/all".to_string(),
|
||||
];
|
||||
for service_url in &services {
|
||||
if let Ok(resp) = client
|
||||
.post(service_url)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
|
||||
info!("Scraped {} leads from {}", leads_data.len(), service_url);
|
||||
let mut store = state.leads.lock().unwrap();
|
||||
for item in &leads_data {
|
||||
store.push(Lead {
|
||||
title: item["title"].as_str().unwrap_or("").chars().take(120).collect(),
|
||||
url: item["url"].as_str().unwrap_or("").to_string(),
|
||||
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
||||
found_at: now,
|
||||
author: item["author"].as_str().unwrap_or("").chars().take(60).collect(),
|
||||
date: item["date"].as_str().unwrap_or("").chars().take(30).collect(),
|
||||
content: item["content"].as_str().unwrap_or("").chars().take(300).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Scraper service unreachable: {}", service_url);
|
||||
}
|
||||
}
|
||||
|
||||
let recent_leads = state.leads.lock().unwrap().recent(604800, 20);
|
||||
let response = format_leads_output(&recent_leads);
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(Uuid::parse_str(&req.user_id).unwrap_or(Uuid::nil()))
|
||||
.bind(&req.user_role)
|
||||
.bind(&req.message)
|
||||
.bind(&response)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return Ok(Json(ChatResponse { response }));
|
||||
}
|
||||
|
||||
let recent_leads = state.leads.lock().unwrap().recent(604800, 20);
|
||||
|
||||
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
|
||||
|
||||
let message_text = req.message.clone();
|
||||
|
||||
let ollama_req = OllamaRequest {
|
||||
model: state.model.clone(),
|
||||
messages: vec![
|
||||
OllamaChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: system_prompt,
|
||||
},
|
||||
OllamaChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: req.message.clone(),
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: OllamaOptions {
|
||||
temperature: 0.7,
|
||||
num_predict: 1024,
|
||||
},
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/api/chat", state.ollama_url))
|
||||
.json(&ollama_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Ollama request failed: {}", e);
|
||||
(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI service unavailable".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
|
||||
error!("Failed to parse Ollama response: {}", e);
|
||||
(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI response parse error".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let response_text = ollama_resp
|
||||
.message
|
||||
.map(|m| m.content)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Store in database
|
||||
let user_id = Uuid::parse_str(&req.user_id).unwrap_or(Uuid::nil());
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(user_id)
|
||||
.bind(&req.user_role)
|
||||
.bind(&message_text)
|
||||
.bind(&response_text)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok(Json(ChatResponse {
|
||||
response: response_text,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Jobs handler ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_jobs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<JobsResponse> {
|
||||
Json(JobsResponse {
|
||||
jobs: state.jobs.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Health handler ─────────────────────────────────────────────
|
||||
|
||||
async fn handle_health(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
model: state.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "crm_ai=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let ollama_url =
|
||||
std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let port: u16 = std::env::var("AI_PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.expect("AI_PORT must be a number");
|
||||
|
||||
// Load jobs
|
||||
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
|
||||
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
|
||||
let jobs: Vec<Job> = jobs_content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
"Loaded {} job categories, model: {}, Ollama: {}",
|
||||
jobs.len(),
|
||||
model,
|
||||
ollama_url
|
||||
);
|
||||
|
||||
// Connect to database
|
||||
let db = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
info!("Connected to PostgreSQL");
|
||||
|
||||
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
ollama_url,
|
||||
model,
|
||||
jobs,
|
||||
leads: lead_store.clone(),
|
||||
});
|
||||
|
||||
// CORS layer
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ai/chat", post(handle_chat))
|
||||
.route("/ai/jobs", get(handle_jobs))
|
||||
.route("/health", get(handle_health))
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
info!("CRM AI server listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect("Failed to bind address");
|
||||
|
||||
// Start background scrapers
|
||||
let bg_leads = lead_store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let bg_client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build().ok();
|
||||
loop {
|
||||
if let Some(ref c) = bg_client {
|
||||
let urls = vec![
|
||||
"http://localhost:3008/scrape/all".to_string(),
|
||||
];
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
for url in &urls {
|
||||
if let Ok(resp) = c.post(url).send() {
|
||||
if let Ok(data) = resp.json::<Vec<serde_json::Value>>() {
|
||||
let mut store = bg_leads.lock().unwrap();
|
||||
for item in &data {
|
||||
store.push(Lead {
|
||||
title: item["title"].as_str().unwrap_or("").chars().take(120).collect(),
|
||||
url: item["url"].as_str().unwrap_or("").to_string(),
|
||||
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
||||
found_at: now,
|
||||
author: item["author"].as_str().unwrap_or("").chars().take(60).collect(),
|
||||
date: item["date"].as_str().unwrap_or("").chars().take(30).collect(),
|
||||
content: item["content"].as_str().unwrap_or("").chars().take(300).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let delay = rand::thread_rng().gen_range(120..300);
|
||||
std::thread::sleep(Duration::from_secs(delay));
|
||||
}
|
||||
});
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server failed");
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user