Fix API errors and improve customer discovery with real web results
- Fix usage/stats 500: use Date() not datetime.date() for SQL cast - Fix customers 422: raise size limit to 1000 - Replace unreliable MCP client with direct Bing batch search for discovery - Batch all search queries in one browser session (faster) - Show real company names/URLs from Bing, not generic templates - Smart filter for non-business results (news, blogs, forums) - Fallback suggestions when search results are insufficient - Frontend: clickable contact URLs, provider indicator, better layout
This commit is contained in:
@@ -28,10 +28,11 @@ class NvidiaProvider(OpenAIProvider):
|
||||
messages.append({"role": "user", "content": message})
|
||||
t1 = time.time()
|
||||
|
||||
max_tokens = 800 if "JSON" in (system or "").upper() else 300
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"max_tokens": 300,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
resp = await self.client.chat.completions.create(**kwargs)
|
||||
|
||||
@@ -17,7 +17,7 @@ router = APIRouter()
|
||||
async def list_customers(
|
||||
status: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
size: int = Query(20, ge=1, le=1000),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
_batch_search.js
|
||||
+129
-105
@@ -1,11 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, Union
|
||||
|
||||
from app.ai.router import get_ai_router
|
||||
from app.services.search_web import search_companies, fetch_page_text
|
||||
from app.services.mcp_search_client import mcp_search
|
||||
from app.services.mcp_search_server import search_bing_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,27 +34,32 @@ class DiscoveryService:
|
||||
|
||||
async def search(self, product_description: str, target_market: str) -> Dict[str, Any]:
|
||||
queries = self._build_queries(product_description, target_market)
|
||||
all_results = await self._mcp_search_all(queries)
|
||||
|
||||
all_results = await self._web_search_all(queries)
|
||||
companies = []
|
||||
provider = "template"
|
||||
if all_results:
|
||||
raw = all_results.get("results", [])
|
||||
companies = [self._to_company(r) for r in raw[:12]]
|
||||
provider = all_results.get("provider", "web_search")
|
||||
|
||||
good_enough = [c for c in companies if self._looks_like_business(c)]
|
||||
if len(good_enough) < 3:
|
||||
logger.info(f"Web search returned only {len(good_enough)} good results, supplementing with suggestions")
|
||||
extras = self._suggest_companies(product_description, target_market)
|
||||
seen_names = set(c.get("name", "") for c in good_enough)
|
||||
for c in extras:
|
||||
if c.get("name") and c["name"] not in seen_names:
|
||||
seen_names.add(c["name"])
|
||||
good_enough.append(c)
|
||||
|
||||
return {
|
||||
"companies": all_results[:15],
|
||||
"companies": good_enough[:15],
|
||||
"query": product_description,
|
||||
"market": target_market,
|
||||
"provider": "mcp_search",
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
all_results = await self._google_search_all(queries)
|
||||
if all_results:
|
||||
return {
|
||||
"companies": all_results[:15],
|
||||
"query": product_description,
|
||||
"market": target_market,
|
||||
"provider": "web_search",
|
||||
}
|
||||
|
||||
logger.info("No real search results, using AI strategy")
|
||||
return await self._ai_strategy(product_description, target_market)
|
||||
|
||||
async def analyze(self, company_url: str, product_description: str) -> Dict[str, Any]:
|
||||
page_text = await fetch_page_text(company_url)
|
||||
company_info = {"url": company_url}
|
||||
@@ -117,83 +121,115 @@ URL: {company_url}
|
||||
logger.warning(f"Outreach AI parse failed: {e}")
|
||||
return self._template_outreach(company_info, product_info)
|
||||
|
||||
async def _mcp_search_all(self, queries: list) -> list:
|
||||
seen_urls = set()
|
||||
tasks = [asyncio.create_task(mcp_search(q, max_results=6)) for q in queries[:2]]
|
||||
all_results = []
|
||||
async def _web_search_all(self, queries: list) -> dict:
|
||||
try:
|
||||
for coro in asyncio.as_completed(tasks, timeout=8):
|
||||
try:
|
||||
results = await coro
|
||||
for r in results:
|
||||
url = r.get("url", "").rstrip("/")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
all_results.append(r)
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
logger.debug(f"MCP search query failed: {e}")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("MCP search overall timeout")
|
||||
finally:
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
if all_results:
|
||||
return self._dedup_and_filter(all_results)[:15]
|
||||
return []
|
||||
results = await search_bing_batch(queries[:4], max_per_query=5)
|
||||
if results:
|
||||
return {"results": self._dedup_and_filter(results)[:15], "provider": "bing"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Bing batch search failed: {e}")
|
||||
|
||||
results = await search_companies(queries[0], max_results=10)
|
||||
if results:
|
||||
return {"results": results[:15], "provider": "google_cse"}
|
||||
|
||||
return {}
|
||||
|
||||
def _dedup_and_filter(self, results: list) -> list:
|
||||
seen = set()
|
||||
filtered = []
|
||||
junk = ["sciencedirect", "mdpi", "springer", "wiley", "acm.org",
|
||||
"ieee.org", "researchgate", "nature.com", "oup.com",
|
||||
"sagepub", "tandfonline", "ncbi", "semanticscholar",
|
||||
"britannica", "dictionary", "cambridge", "iciba", "wikipedia",
|
||||
"w3.org", "whatsapp.com", "wechat.com", "qq.com",
|
||||
"zhihu.com", "sogou.com", "163.com", "sohu.com", "sina.com",
|
||||
"taobao.com", "tmall.com", "alipay.com", "alibaba.com",
|
||||
"csdn.net", "blog.csdn", "jianshu.com", "36kr.com",
|
||||
"huxiu.com", "geekpark.net", "leiphone.com",
|
||||
"medium.com", "wordpress.com", "blogspot.com",
|
||||
"youtube.com", "facebook.com", "twitter.com", "instagram.com",
|
||||
"reddit.com", "quora.com"]
|
||||
for r in results:
|
||||
url = r.get("url", "").rstrip("/")
|
||||
title = r.get("title", "")
|
||||
if not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
s = url.split("/")[2] if "://" in url else url
|
||||
hostname = s.split(":")[0].lower() if ":" in s else s.lower()
|
||||
if any(tld in hostname for tld in [".cn", ".com.cn", ".edu", ".ac.", ".gov"]):
|
||||
if any(tld in hostname for tld in [".edu", ".ac.", ".gov", ".edu.cn"]):
|
||||
continue
|
||||
if any(domain in hostname for domain in
|
||||
["sciencedirect", "mdpi", "springer", "wiley", "acm.org",
|
||||
"ieee.org", "researchgate", "nature.com", "oup.com",
|
||||
"sagepub", "tandfonline", "ncbi", "semanticscholar",
|
||||
"britannica", "dictionary", "cambridge", "iciba", "wikipedia"]):
|
||||
if any(domain in hostname for domain in junk):
|
||||
continue
|
||||
filtered.append(r)
|
||||
return filtered
|
||||
|
||||
async def _google_search_all(self, queries: list) -> list:
|
||||
all_results = []
|
||||
seen_urls = set()
|
||||
for q in queries[:3]:
|
||||
results = await search_companies(q, max_results=8)
|
||||
for r in results:
|
||||
url = r["url"].rstrip("/")
|
||||
if url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
all_results.append(r)
|
||||
if len(all_results) >= 15:
|
||||
break
|
||||
return self._dedup_and_filter(all_results)[:15]
|
||||
def _to_company(self, r: dict) -> dict:
|
||||
url = r.get("url", "")
|
||||
title = r.get("title", url)[:60]
|
||||
snippet = r.get("snippet", "")[:200]
|
||||
return {
|
||||
"name": title,
|
||||
"description": snippet,
|
||||
"country": "",
|
||||
"match_score": 60,
|
||||
"contact": url[:100] if url else "暂无",
|
||||
"source": "web",
|
||||
}
|
||||
|
||||
def _looks_like_business(self, c: dict) -> bool:
|
||||
name = c.get("name", "")
|
||||
snippet = c.get("description", "")
|
||||
junk_words = ["news", "review", "blog", "dictionary", "translate",
|
||||
"wikipedia", "百科", "词典", "新闻", "评测",
|
||||
"price", "shop", "buy online", "forum", "subscribe",
|
||||
"专业媒体", "行业媒体", "新媒体", "门户"]
|
||||
name_lower = name.lower()
|
||||
if any(w in name_lower for w in junk_words):
|
||||
return False
|
||||
snippet_lower = snippet.lower()
|
||||
newsy = ["news", "review", "blog post", "article", "dictionary",
|
||||
"专业媒体", "行业媒体", "新媒体"]
|
||||
if any(w in snippet_lower for w in newsy):
|
||||
biz_words = ["company", "inc", "ltd", "corp", "gmbh", "llc",
|
||||
"manufacturer", "supplier", "exporter",
|
||||
"wholesale", "distributor", "trading",
|
||||
"import", "enterprise", "co.", "factory",
|
||||
"industry", "electric", "solar", "energy",
|
||||
"automotive", "vehicle", "官网", "有限公司",
|
||||
"集团", "股份", "实业"]
|
||||
if not any(w in snippet_lower for w in biz_words):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _build_queries(self, product: str, market: str) -> list:
|
||||
return [
|
||||
import re
|
||||
has_cjk = bool(re.search(r'[\u4e00-\u9fff]', product))
|
||||
queries = [
|
||||
f"{product} importer {market}",
|
||||
f"{product} distributor {market}",
|
||||
f"{product} wholesale buyer {market}",
|
||||
f"{product} procurement {market}",
|
||||
f"{product} company {market}",
|
||||
f"{product} trading company {market}",
|
||||
f"buy {product} from {market}",
|
||||
f"{product} supply chain {market}",
|
||||
f"top {product} manufacturers {market}",
|
||||
f"{product} import export {market}",
|
||||
f"{product} trading company {market}",
|
||||
]
|
||||
if has_cjk:
|
||||
queries += [
|
||||
f"{product} {market} 进口商",
|
||||
f"{product} {market} 经销商",
|
||||
f"{product} {market} 采购",
|
||||
f"{product} {market} 批发",
|
||||
]
|
||||
b2b_queries = [
|
||||
f"{product} buyer {market} alibaba",
|
||||
f"{product} supplier {market}",
|
||||
f"{product} wholesale price {market}",
|
||||
]
|
||||
return queries + b2b_queries
|
||||
|
||||
def _extract_json(self, text: str) -> Optional[dict]:
|
||||
def _extract_json(self, text: str) -> Optional[Union[dict, list]]:
|
||||
text = text.strip()
|
||||
for prefix in ["```json", "```", "```JSON"]:
|
||||
if text.startswith(prefix):
|
||||
@@ -215,49 +251,37 @@ URL: {company_url}
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _ai_strategy(self, product: str, market: str) -> Dict[str, Any]:
|
||||
if not self._ai_available:
|
||||
return self._template_strategy(product, market)
|
||||
system = """你是外贸客户发现专家。根据用户的产品和目标市场,列出15家有可能采购该产品的潜在公司。
|
||||
|
||||
请以 JSON 格式返回(不要用 markdown 代码块标记):
|
||||
{
|
||||
"companies": [
|
||||
{"name": "公司名称", "description": "公司业务简介", "country": "所在国家", "match_score": 匹配度0-100, "contact": "联系方式(有就写,没有写'需进一步查找')", "source": "推荐来源说明"}
|
||||
],
|
||||
"strategy": "整体获取策略建议",
|
||||
"tips": ["搜索建议1", "搜索建议2"]
|
||||
}
|
||||
|
||||
要求:
|
||||
- 公司名称要真实感,不要编造知名大公司
|
||||
- 公司业务要与产品相关
|
||||
- 匹配度要有区分度,60-95之间
|
||||
- 至少返回10家
|
||||
- 只返回 JSON,不要其他内容"""
|
||||
|
||||
prompt = f"产品:{product}\n目标市场:{market}\n请列出在该市场可能采购该产品的公司。"
|
||||
try:
|
||||
result = await self.ai.chat(prompt, system_prompt=system)
|
||||
content = result.get("reply", "")
|
||||
parsed = self._extract_json(content)
|
||||
if parsed and "companies" in parsed:
|
||||
parsed["provider"] = result.get("provider_used", "unknown")
|
||||
parsed["ai_generated"] = True
|
||||
return parsed
|
||||
return self._template_strategy(product, market)
|
||||
except Exception as e:
|
||||
logger.warning(f"AI strategy failed: {e}")
|
||||
return self._template_strategy(product, market)
|
||||
def _suggest_companies(self, product: str, market: str) -> list:
|
||||
return [
|
||||
{"name": f"{product} Importers in {market}", "description": f"在 {market} 从事 {product} 进口和批发的贸易商和专业进口商", "country": market, "match_score": 80, "contact": f"在 LinkedIn 搜索 '{product} importer {market}'", "source": "建议"},
|
||||
{"name": f"{product} Distributors in {market}", "description": f"在 {market} 分销 {product} 的分销渠道商和批发商", "country": market, "match_score": 75, "contact": f"在 Google/Bing 搜索 '{product} distributor {market}'", "source": "建议"},
|
||||
{"name": f"{market} Trade Association", "description": f"联系 {market} 的相关行业协会获取会员企业名录", "country": market, "match_score": 70, "contact": f"搜索 '{market} {product} association'", "source": "建议"},
|
||||
{"name": f"Alibaba {market} Buyers", "description": f"在 Alibaba.com 搜索 '{product}' 并筛选 {market} 买家", "country": market, "match_score": 75, "contact": "https://www.alibaba.com", "source": "建议"},
|
||||
{"name": f"LinkedIn {market} Decision Makers", "description": f"在 LinkedIn 搜索 '{market} {product} procurement/sourcing manager' 找决策人", "country": market, "match_score": 65, "contact": "LinkedIn Premium", "source": "建议"},
|
||||
{"name": f"{market} Import-Export Records", "description": f"在 importgenius.com 搜索 {product} 在 {market} 的进口记录,找到真实买家", "country": market, "match_score": 70, "contact": "https://www.importgenius.com", "source": "建议"},
|
||||
]
|
||||
|
||||
def _template_strategy(self, product: str, market: str) -> Dict[str, Any]:
|
||||
search_terms = [
|
||||
f"{product} importer {market}",
|
||||
f"{product} distributor {market}",
|
||||
f"{product} wholesale {market}",
|
||||
f"{product} buyers {market}",
|
||||
]
|
||||
b2b_sites = ["Alibaba.com", "TradeIndia.com", "GlobalSources.com", "Made-in-China.com"]
|
||||
return {
|
||||
"companies": [
|
||||
{"name": f"{product} Importers in {market} (示例)", "description": f"在{market}从事{product}进口和批发的贸易商,建议在LinkedIn上搜索相关关键词", "country": market, "match_score": 75, "contact": "需进一步查找", "source": "AI推荐"},
|
||||
{"name": f"{product} Distributors in {market} (示例)", "description": f"在{market}分销{product}的渠道商,建议通过Google搜索关键词", "country": market, "match_score": 70, "contact": "需进一步查找", "source": "AI推荐"},
|
||||
{"name": f"{product} Importers in {market}", "description": f"使用Google/Bing搜索 '{product} importer {market}' 可找到正在采购该产品的进口商", "country": market, "match_score": 80, "contact": "通过搜索结果获取", "source": "搜索建议"},
|
||||
{"name": f"{product} Distributors in {market}", "description": f"使用Google/Bing搜索 '{product} distributor {market}' 可找到分销渠道商", "country": market, "match_score": 75, "contact": "通过搜索结果获取", "source": "搜索建议"},
|
||||
{"name": f"{product} Wholesale Buyers in {market}", "description": f"使用Google/Bing搜索 '{product} wholesale {market}' 可找到批发采购商", "country": market, "match_score": 70, "contact": "通过搜索结果获取", "source": "搜索建议"},
|
||||
{"name": f"{market} {product} Trade Partners", "description": f"在B2B平台({', '.join(b2b_sites[:3])})搜索 {market} 买家发布的采购需求", "country": market, "match_score": 65, "contact": "B2B平台站内信", "source": "B2B平台"},
|
||||
],
|
||||
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market} 的 {product} 相关公司,使用导入商、批发商、经销商等关键词组合",
|
||||
"tips": ["使用多个搜索词组合", "找到公司后在 LinkedIn 找决策人", "查看公司网站了解其业务范围"],
|
||||
"strategy": f"推荐搜索计划:\n1. 搜索 '{' '.join(search_terms[:2])}' 直接找客户\n2. 在 LinkedIn 搜索 '{market} {product} manager' 找决策人\n3. 在 {b2b_sites[0]} 和 {b2b_sites[1]} 查找 {market} 买家询盘\n4. 参加 {market} 相关行业展会获取名录",
|
||||
"tips": [
|
||||
f"把搜索词 '{product} importer {market}' 改成当地语言效果更好",
|
||||
"找到公司后访问官网,在 About/Team 页面找决策人LinkedIn",
|
||||
"用 SimilarWeb 查看目标公司网站流量和来源",
|
||||
"在行业协会网站查找会员名录"],
|
||||
"provider": "template",
|
||||
"ai_generated": True,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import List, Dict
|
||||
import functools
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
@@ -11,22 +12,29 @@ logger = logging.getLogger(__name__)
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
NODE_BIN = "/usr/bin/node"
|
||||
|
||||
BING_SCRIPT = r"""
|
||||
BATCH_SCRIPT = r"""
|
||||
const p = require('puppeteer');
|
||||
(async () => {
|
||||
const b = await p.launch({headless:true,args:['--no-sandbox','--disable-setuid-sandbox','--disable-blink-features=AutomationControlled']});
|
||||
const queries = JSON.parse(process.argv[process.argv.length - 2]);
|
||||
const max = parseInt(process.argv[process.argv.length - 1] || '6', 10);
|
||||
const sk = ['bing.com','google.com','facebook.com','twitter.com','instagram.com','youtube.com','reddit.com','amazon.com','walmart.com','w3.org','whatsapp.com','wechat.com','qq.com','taobao.com','tmall.com','alipay.com','zhihu.com','baike.baidu.com','sogou.com','163.com','sohu.com','sina.com','iciba.com','cambridge','britannica','sciencedirect','mdpi.com','springer','wiley.com','acm.org','ieee.org','researchgate','semanticscholar','ncbi.nlm.nih','nature.com','oup.com','sagepub','tandfonline','pinterest','ebay','dictionary','translate'];
|
||||
|
||||
try {
|
||||
const b = await p.launch({headless:true,args:['--no-sandbox','--disable-setuid-sandbox','--disable-blink-features=AutomationControlled'],timeout:10000});
|
||||
const allResults = [];
|
||||
const seenUrls = new Set();
|
||||
|
||||
for (const q of queries) {
|
||||
try {
|
||||
const page = await b.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
await page.setExtraHTTPHeaders({'Accept-Language':'en-US,en;q=0.9'});
|
||||
await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', {get:()=>undefined}); });
|
||||
const q = process.argv[process.argv.length - 2];
|
||||
const max = parseInt(process.argv[process.argv.length - 1] || '10', 10);
|
||||
const sk = ['bing.com','google.com','facebook.com','twitter.com','instagram.com','youtube.com','reddit.com','amazon.com','wikipedia.org','baidu.com','linkedin.com','pinterest.com','ebay.com','walmart.com','w3.org','whatsapp.com','wechat.com','qq.com','taobao.com','tmall.com','alibaba.com','alipay.com','dict','dictionary','translate','zhihu.com','baike.baidu.com','sogou.com','163.com','sohu.com','sina.com','iciba.com','cambridge','britannica','sciencedirect','mdpi.com','springer','wiley.com','acm.org','ieee.org','researchgate','semanticscholar','ncbi.nlm.nih','nature.com','oup.com','sagepub','tandfonline'];
|
||||
try {
|
||||
await page.goto('https://cn.bing.com/search?q=' + encodeURIComponent(q) + '&setlang=en-US&cc=US', {waitUntil:'domcontentloaded',timeout:10000});
|
||||
await page.waitForSelector('.b_algo', {timeout:5000}).catch(()=>{});
|
||||
|
||||
const url = 'https://www.bing.com/search?q=' + encodeURIComponent(q) + '&setlang=en-US&cc=US';
|
||||
await page.goto(url, {waitUntil:'domcontentloaded',timeout:8000});
|
||||
await page.waitForSelector('.b_algo', {timeout:4000}).catch(()=>{});
|
||||
|
||||
const results = await page.evaluate((m, sk) => {
|
||||
const reCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/;
|
||||
const found = []; const seen = new Set();
|
||||
document.querySelectorAll('li.b_algo').forEach(li => {
|
||||
const a = li.querySelector('h2 a'); if (!a) return;
|
||||
@@ -35,55 +43,64 @@ const p = require('puppeteer');
|
||||
seen.add(url);
|
||||
if (sk.some(d => url.includes(d))) return;
|
||||
const hostname = url.replace(/^https?:\/\//,'').split('/')[0];
|
||||
if (hostname.endsWith('.cn') || hostname.endsWith('.com.cn') || hostname.endsWith('.edu') || hostname.endsWith('.ac')) return;
|
||||
if (hostname.endsWith('.edu') || hostname.endsWith('.ac') || hostname.endsWith('.gov')) return;
|
||||
const title = (a.textContent||'').trim().substring(0,100);
|
||||
if (reCJK.test(title)) return;
|
||||
const s = li.querySelector('.b_caption p, .b_lineclamp2');
|
||||
found.push({title, url, snippet:s?s.textContent.trim().substring(0,200):''});
|
||||
});
|
||||
return found.slice(0,m);
|
||||
}, max, sk);
|
||||
console.log(JSON.stringify(results));
|
||||
} catch(e) { console.log('[]'); }
|
||||
|
||||
for (const r of results) {
|
||||
if (!seenUrls.has(r.url)) {
|
||||
seenUrls.add(r.url);
|
||||
allResults.push(r);
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
} catch(e) { /* skip failed query */ }
|
||||
}
|
||||
console.log(JSON.stringify(allResults.slice(0, max * queries.length)));
|
||||
await b.close();
|
||||
} catch(e) { console.log('[]'); }
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
BING_SCRIPT_FILE = os.path.join(os.path.dirname(__file__), "_bing_search.js")
|
||||
BATCH_SCRIPT_FILE = os.path.join(os.path.dirname(__file__), "_batch_search.js")
|
||||
NODE_MODULES = os.path.join(PROJECT_ROOT, "node_modules")
|
||||
|
||||
|
||||
async def search_bing(query: str, max_results: int = 10) -> List[Dict[str, str]]:
|
||||
async def search_bing_batch(queries: List[str], max_per_query: int = 6) -> List[Dict[str, str]]:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
with open(BING_SCRIPT_FILE, "w") as f:
|
||||
f.write(BING_SCRIPT)
|
||||
with open(BATCH_SCRIPT_FILE, "w") as f:
|
||||
f.write(BATCH_SCRIPT)
|
||||
env = os.environ.copy()
|
||||
env["NODE_PATH"] = NODE_MODULES
|
||||
result = subprocess.run(
|
||||
[NODE_BIN, BING_SCRIPT_FILE, query, str(max_results)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=PROJECT_ROOT,
|
||||
env=env,
|
||||
fn = functools.partial(
|
||||
subprocess.run,
|
||||
[NODE_BIN, BATCH_SCRIPT_FILE, json.dumps(queries), str(max_per_query)],
|
||||
capture_output=True, text=True, timeout=120, cwd=PROJECT_ROOT, env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"Bing search failed: {result.stderr[:300]}")
|
||||
return []
|
||||
result = await loop.run_in_executor(None, fn)
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
return json.loads(line)
|
||||
return []
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Bing search timed out")
|
||||
logger.warning("Bing batch search timed out")
|
||||
return []
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.warning(f"Bing search error: {e}")
|
||||
logger.warning(f"Bing batch search error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def search_bing(query: str, max_results: int = 10) -> List[Dict[str, str]]:
|
||||
return await search_bing_batch([query], max_per_query=max_results)
|
||||
|
||||
|
||||
mcp = FastMCP("trade-search", log_level="WARNING")
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, Depends
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Date
|
||||
import logging
|
||||
|
||||
from app.models import UsageLog, SystemConfig, User, Customer, Product
|
||||
@@ -52,7 +53,7 @@ class UsageService:
|
||||
stmt = select(func.count()).where(
|
||||
UsageLog.user_id == user_id,
|
||||
UsageLog.action == action,
|
||||
func.cast(UsageLog.created_at, date) == today,
|
||||
func.cast(UsageLog.created_at, Date) == today,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
@@ -64,7 +65,7 @@ class UsageService:
|
||||
), 0)).where(
|
||||
UsageLog.user_id == user_id,
|
||||
UsageLog.action == "translate",
|
||||
func.cast(UsageLog.created_at, date) == today,
|
||||
func.cast(UsageLog.created_at, Date) == today,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
@@ -11,12 +11,24 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="results.length">
|
||||
<el-card v-for="r in results" :key="r.id || r.name" shadow="hover" style="margin-top:12px">
|
||||
<h4>{{ r.name }} <el-tag v-if="r.match_score" size="small" :type="scoreType(r.match_score)">{{ r.match_score }}%</el-tag></h4>
|
||||
<p v-if="r.description" style="color:#666;font-size:13px">{{ r.description }}</p>
|
||||
<p v-if="r.contact" style="font-size:12px;color:#999">联系方式:{{ r.contact }}</p>
|
||||
<div style="margin-top:8px">
|
||||
<el-alert v-if="provider === 'template' || provider === '建议'" title="以下为搜索策略建议,可用于手动开发客户" type="warning" show-icon :closable="false" style="margin-bottom:12px" />
|
||||
<el-alert v-if="provider === 'bing'" title="以下为搜索到的相关公司,点击'访问网站'了解更多,也可添加为客户" type="info" show-icon :closable="false" style="margin-bottom:12px" />
|
||||
<el-card v-for="(r, idx) in results" :key="r.id || r.name || idx" shadow="hover" style="margin-top:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h4 style="margin:0">{{ r.name }} <el-tag v-if="r.match_score" size="small" :type="scoreType(r.match_score)">{{ r.match_score }}%</el-tag></h4>
|
||||
<el-tag v-if="r.source && provider !== 'template'" size="small" type="info">{{ r.source }}</el-tag>
|
||||
</div>
|
||||
<p v-if="r.description" style="color:#666;font-size:13px;margin:8px 0">{{ r.description }}</p>
|
||||
<p v-if="r.contact" style="font-size:12px;color:#999;margin:4px 0">
|
||||
联系方式:
|
||||
<template v-if="r.contact.startsWith('http')">
|
||||
<a :href="r.contact" target="_blank" rel="noopener">{{ r.contact.substring(0, 50) }}{{ r.contact.length > 50 ? '…' : '' }}</a>
|
||||
</template>
|
||||
<template v-else>{{ r.contact }}</template>
|
||||
</p>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<el-button size="small" type="primary" @click="addCustomer(r)">添加为客户</el-button>
|
||||
<el-button v-if="r.contact && r.contact.startsWith('http')" size="small" @click="openUrl(r.contact)">访问网站</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -55,6 +67,9 @@ const tab = ref('search')
|
||||
const loading = ref(false)
|
||||
const searched = ref(false)
|
||||
const results = ref([])
|
||||
const provider = ref('')
|
||||
|
||||
function openUrl(url) { window.open(url, '_blank') }
|
||||
const form = ref({ product: '', market: '' })
|
||||
const outForm = ref({ company: '', product: '', channel: 'email' })
|
||||
const outLoading = ref(false)
|
||||
@@ -73,6 +88,7 @@ async function search() {
|
||||
})
|
||||
const d = res.data || res
|
||||
results.value = d.companies || d.items || d.results || d || []
|
||||
provider.value = d.provider || ''
|
||||
} catch { ElMessage.error('挖掘失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user