mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
Added Ai load fix
This commit is contained in:
+132
-9
@@ -372,6 +372,7 @@ OFFER_PATTERNS = [
|
|||||||
r"\bmy\s+portfolio\b",
|
r"\bmy\s+portfolio\b",
|
||||||
r"\breasonable\s+(price|pricing|rate)\b",
|
r"\breasonable\s+(price|pricing|rate)\b",
|
||||||
r"\bwhatsapp\s+(me|us|at)\b",
|
r"\bwhatsapp\s+(me|us|at)\b",
|
||||||
|
r"\bwhatsapp\b",
|
||||||
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
|
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
|
||||||
r"\bhelp\s+your\s+business\b",
|
r"\bhelp\s+your\s+business\b",
|
||||||
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
|
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
|
||||||
@@ -407,6 +408,46 @@ OFFER_PATTERNS = [
|
|||||||
r"\bselling\s+(my|a|the|this)\b",
|
r"\bselling\s+(my|a|the|this)\b",
|
||||||
r"\bpremium\b",
|
r"\bpremium\b",
|
||||||
r"\bi'm\s+selling\b",
|
r"\bi'm\s+selling\b",
|
||||||
|
r"\bgroup\b",
|
||||||
|
r"\bi\s+can\s+help\b",
|
||||||
|
r"\binbox\s+me\b",
|
||||||
|
r"\bpm\s+me\b",
|
||||||
|
r"\bdm\s+me\b",
|
||||||
|
r"\bmessage\s+me\s+for\b",
|
||||||
|
r"\bquote\b",
|
||||||
|
r"\bdiscount\b",
|
||||||
|
r"\bbest\s+(deal|price|offer|service)\b",
|
||||||
|
r"\bprice\s+(start|begin|include|range)\b",
|
||||||
|
r"\breach\s+out\b",
|
||||||
|
r"\bclick\s+(the\s+)?link\b",
|
||||||
|
r"\bcheck\s+(out|this|my)\b",
|
||||||
|
r"\bwebsite\s+for\s+your\b",
|
||||||
|
r"\bprobono\b",
|
||||||
|
r"\bfreelance\s+opportunit",
|
||||||
|
r"\bfull.?stack\b",
|
||||||
|
r"\bresponsive\s+(web)?sites?\b",
|
||||||
|
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
|
||||||
|
r"\bmern\s+stack\b",
|
||||||
|
r"\bexpress\s+js\b",
|
||||||
|
r"\breact\s+js\b",
|
||||||
|
r"\bnode\s+js\b",
|
||||||
|
r"\bwebsite\s+for\s+\$\b",
|
||||||
|
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bfull.?time\s+(position|job|role|work)\b",
|
||||||
|
r"\bremote\s+(position|job|role|work)\b",
|
||||||
|
r"\bhelp\s+wanted\b",
|
||||||
|
r"\bjob\s+(opening|vacancy|opportunity)\b",
|
||||||
|
r"\bnow\s+hiring\b",
|
||||||
|
r"\bpart.?time\s+(position|job|role|work)\b",
|
||||||
|
r"\byears?\s+of\s+(teaching|experience)\b",
|
||||||
|
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
|
||||||
|
r"\bcomment\s+.*\bsend\b",
|
||||||
|
r"\bfor\s+free\b",
|
||||||
|
r"\bmake\s+money\b",
|
||||||
|
r"\bno\s+coding\b",
|
||||||
]
|
]
|
||||||
|
|
||||||
REQUEST_PATTERNS = [
|
REQUEST_PATTERNS = [
|
||||||
@@ -748,7 +789,7 @@ def _clean_fb_text(text: str) -> str:
|
|||||||
return '\n'.join(cleaned_lines)
|
return '\n'.join(cleaned_lines)
|
||||||
|
|
||||||
# Words that should never be treated as person names
|
# 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"}
|
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
|
||||||
|
|
||||||
# ── Post Extraction ──────────────────────────────────────────────────
|
# ── Post Extraction ──────────────────────────────────────────────────
|
||||||
# Two strategies for extracting posts from page content:
|
# Two strategies for extracting posts from page content:
|
||||||
@@ -756,10 +797,22 @@ _NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgentl
|
|||||||
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
|
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
|
||||||
# Both apply dedup (seen_texts), offer filtering, and request scoring.
|
# Both apply dedup (seen_texts), offer filtering, and request scoring.
|
||||||
|
|
||||||
|
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
|
||||||
|
|
||||||
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
|
now = datetime.utcnow()
|
||||||
for el in elements:
|
for el in elements:
|
||||||
|
# Reject group posts immediately
|
||||||
|
if el.get('isGroup'):
|
||||||
|
continue
|
||||||
|
# Reject posts older than 2 days
|
||||||
|
ts = el.get('ts', 0)
|
||||||
|
if ts:
|
||||||
|
age_seconds = (now.timestamp() * 1000 - ts) / 1000
|
||||||
|
if age_seconds > 172800: # 2 days
|
||||||
|
continue
|
||||||
raw_text = el.get('text', '')
|
raw_text = el.get('text', '')
|
||||||
if len(raw_text) < 40:
|
if len(raw_text) < 40:
|
||||||
continue
|
continue
|
||||||
@@ -773,13 +826,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
|
|||||||
if dekey in seen_texts:
|
if dekey in seen_texts:
|
||||||
continue
|
continue
|
||||||
seen_texts.add(dekey)
|
seen_texts.add(dekey)
|
||||||
|
# Reject if text contains group-like patterns
|
||||||
|
lower = text.lower()
|
||||||
|
if ' / ' in lower:
|
||||||
|
continue
|
||||||
|
skip = False
|
||||||
|
for suff in _GROUP_SUFFIXES:
|
||||||
|
if suff.lower() in lower:
|
||||||
|
skip = True
|
||||||
|
break
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
request_score = 2 if is_request(text) else 0
|
request_score = 2 if is_request(text) else 0
|
||||||
post_url = el.get('url') or base_url
|
post_url = el.get('url') or base_url
|
||||||
|
|
||||||
# Prefer JS-extracted date, fall back to text parsing
|
# Prefer JS-extracted date, fall back to text parsing
|
||||||
js_date = (el.get('date') or '').strip()
|
js_date = (el.get('date') or '').strip()
|
||||||
if js_date:
|
if js_date:
|
||||||
# Try ISO datetime from <time datetime>
|
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
||||||
date_str = dt.strftime('%Y-%m-%d')
|
date_str = dt.strftime('%Y-%m-%d')
|
||||||
@@ -883,7 +946,6 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
if name:
|
if name:
|
||||||
p['author'] = name
|
p['author'] = name
|
||||||
# Remove group posts: any post whose text looks like it came from a group
|
# 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 = []
|
filtered = []
|
||||||
for p in posts:
|
for p in posts:
|
||||||
t = (p.get('title') or p.get('content') or '')
|
t = (p.get('title') or p.get('content') or '')
|
||||||
@@ -891,14 +953,36 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
lower = t.lower()
|
lower = t.lower()
|
||||||
skip = False
|
skip = False
|
||||||
for suff in _group_suffixes:
|
for suff in _GROUP_SUFFIXES:
|
||||||
if suff.lower() in lower:
|
if suff.lower() in lower:
|
||||||
skip = True
|
skip = True
|
||||||
break
|
break
|
||||||
if skip:
|
if skip:
|
||||||
continue
|
continue
|
||||||
filtered.append(p)
|
filtered.append(p)
|
||||||
return filtered
|
# Dedup: merge posts where one is a suffix of another (same post split)
|
||||||
|
merged = []
|
||||||
|
for p in filtered:
|
||||||
|
t = (p.get('title') or p.get('content') or '').lower()
|
||||||
|
# Check if this post is substantially contained in an already-merged post
|
||||||
|
found = False
|
||||||
|
for i, m in enumerate(merged):
|
||||||
|
mt = (m.get('title') or m.get('content') or '').lower()
|
||||||
|
# Word-level overlap: count shared words
|
||||||
|
twords = set(t.split())
|
||||||
|
mwords = set(mt.split())
|
||||||
|
if len(twords) > 3 and len(mwords) > 3:
|
||||||
|
overlap = len(twords & mwords)
|
||||||
|
smaller = min(len(twords), len(mwords))
|
||||||
|
if overlap / smaller >= 0.6:
|
||||||
|
# Keep the longer one
|
||||||
|
if len(t) > len(mt):
|
||||||
|
merged[i] = p
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
merged.append(p)
|
||||||
|
return merged
|
||||||
|
|
||||||
# ── Human-like Behavior Simulation ──────────────────────────────────
|
# ── Human-like Behavior Simulation ──────────────────────────────────
|
||||||
# These functions add random delays, mouse movements, and scroll patterns
|
# These functions add random delays, mouse movements, and scroll patterns
|
||||||
@@ -950,7 +1034,9 @@ 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 terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce"];
|
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
|
||||||
|
const now = Date.now();
|
||||||
|
const DAY_MS = 86400000;
|
||||||
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
|
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
|
||||||
for (const a of anchors) {
|
for (const a of anchors) {
|
||||||
const h = a.getAttribute('href') || '';
|
const h = a.getAttribute('href') || '';
|
||||||
@@ -966,15 +1052,22 @@ async def _get_article_elements(page) -> list[dict]:
|
|||||||
const key = txt.substring(0, 80);
|
const key = txt.substring(0, 80);
|
||||||
if (seenTexts.has(key)) continue;
|
if (seenTexts.has(key)) continue;
|
||||||
seenTexts.add(key);
|
seenTexts.add(key);
|
||||||
|
|
||||||
|
const isGroup = h.includes('/groups/');
|
||||||
let author = '';
|
let author = '';
|
||||||
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
|
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
|
||||||
if (nameEl) author = (nameEl.innerText || '').trim();
|
if (nameEl) author = (nameEl.innerText || '').trim();
|
||||||
if (!author || author.length > 60 || author.length < 2) author = '';
|
if (!author || author.length > 60 || author.length < 2) author = '';
|
||||||
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
||||||
let date = '';
|
let date = '';
|
||||||
|
let ts = 0;
|
||||||
const timeEl = cell.querySelector('time');
|
const timeEl = cell.querySelector('time');
|
||||||
if (timeEl) date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
if (timeEl) {
|
||||||
results.push({ text: txt, author, url: postUrl, date });
|
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
||||||
|
const dtVal = timeEl.getAttribute('datetime');
|
||||||
|
if (dtVal) { ts = new Date(dtVal).getTime(); }
|
||||||
|
}
|
||||||
|
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}''')
|
}''')
|
||||||
@@ -1119,7 +1212,9 @@ async def search_facebook(page, context, query: str):
|
|||||||
p['author'] = ''
|
p['author'] = ''
|
||||||
# Final filter: strip any remaining group posts
|
# Final filter: strip any remaining group posts
|
||||||
posts = [p for p in posts if not (
|
posts = [p for p in posts if not (
|
||||||
'/groups/' in p.get('url', '') or ' / ' in (p.get('title') or p.get('content') or '')
|
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
|
||||||
|
or '/pages/' in p.get('url', '')
|
||||||
|
or ' / ' in (p.get('title') or p.get('content') or '')
|
||||||
)]
|
)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
@@ -1600,6 +1695,8 @@ NOT LEAD:
|
|||||||
- Recruiting employees, hiring staff, looking for business partners
|
- Recruiting employees, hiring staff, looking for business partners
|
||||||
- Selling products, promoting services, affiliate offers
|
- Selling products, promoting services, affiliate offers
|
||||||
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
||||||
|
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
|
||||||
|
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
|
||||||
|
|
||||||
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
||||||
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
||||||
@@ -1683,6 +1780,28 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
'i\'m selling', 'i\'m offering', 'we\'re offering',
|
'i\'m selling', 'i\'m offering', 'we\'re offering',
|
||||||
'free ecommerce', 'free website design',
|
'free ecommerce', 'free website design',
|
||||||
'starting a', 'looking for a few businesses',
|
'starting a', 'looking for a few businesses',
|
||||||
|
# Group-related rejections
|
||||||
|
'group', ' i need a website group', 'south africa web', 'philippines web', 'australia web',
|
||||||
|
'i can help', 'inbox me', 'dm me', 'pm me', 'message me for',
|
||||||
|
'best price', 'discount', 'reach out', 'check out my', 'check this',
|
||||||
|
'website for your', 'price start', 'price begin', 'website creator',
|
||||||
|
'website & software', 'creators &', 'creators marketplace',
|
||||||
|
'website group', 'page group',
|
||||||
|
# Self-promotion rejections
|
||||||
|
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack", 'i\'m a full stack',
|
||||||
|
'freelance opportunity', 'looking for new project', 'looking for new work',
|
||||||
|
'full stack web', 'mern stack', 'responsive business website',
|
||||||
|
'i build website', 'i build shopify', 'i build wordpress',
|
||||||
|
'we build website', 'we build shopify', 'we build wordpress',
|
||||||
|
'i create website', 'we create website',
|
||||||
|
'full time position', 'full time job', 'remote position', 'remote job',
|
||||||
|
'help wanted', 'now hiring', 'job opening',
|
||||||
|
'website speed', 'speed optimization', 'on page seo', 'off page seo',
|
||||||
|
'elementor pro', 'woocommerce', 'acf', 'custom post type',
|
||||||
|
'send you the link', 'comment website',
|
||||||
|
'for free', 'no coding', 'make money', 'website for free',
|
||||||
|
'part time job', 'part time position',
|
||||||
|
'years of experience', 'years of teaching',
|
||||||
]
|
]
|
||||||
for r in results:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
@@ -1703,7 +1822,9 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
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
|
# Final sweep: strip any remaining offers or group posts from merged
|
||||||
|
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
|
||||||
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
|
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
|
||||||
|
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -1716,6 +1837,8 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
continue
|
continue
|
||||||
if any(kw in t for kw in offer_reject):
|
if any(kw in t for kw in offer_reject):
|
||||||
continue
|
continue
|
||||||
|
if any(gw in t for gw in group_words):
|
||||||
|
continue
|
||||||
seen_titles.add(key)
|
seen_titles.add(key)
|
||||||
merged.append(r)
|
merged.append(r)
|
||||||
if len(merged) >= 5:
|
if len(merged) >= 5:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function formatContent(text: string) {
|
|||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
|
ts?: number
|
||||||
gif?: {
|
gif?: {
|
||||||
url: string
|
url: string
|
||||||
previewUrl: string
|
previewUrl: string
|
||||||
@@ -101,15 +102,21 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [showGifPicker])
|
}, [showGifPicker])
|
||||||
|
|
||||||
|
const _WEEK_MS = 604800000
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem("ai-chat-messages")
|
const saved = localStorage.getItem("ai-chat-messages")
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved)
|
let parsed = JSON.parse(saved)
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
const cutoff = Date.now() - _WEEK_MS
|
||||||
|
parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
|
||||||
|
if (parsed.length > 0) {
|
||||||
setMessages(parsed)
|
setMessages(parsed)
|
||||||
loadedFromStorage.current = true
|
loadedFromStorage.current = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
loadedFromStorage.current = false
|
loadedFromStorage.current = false
|
||||||
@@ -118,7 +125,8 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
localStorage.setItem("ai-chat-messages", JSON.stringify(messages))
|
const now = Date.now()
|
||||||
|
localStorage.setItem("ai-chat-messages", JSON.stringify(messages.map(m => ({ ...m, ts: m.ts || now }))))
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user