This commit is contained in:
Ubuntu
2026-07-03 16:58:28 +08:00
commit 32b43f85bd
6240 changed files with 1987678 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# fumamxCLI - CLI for fumamx.com
__version__ = "0.1.0"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
API 端点发现和测试工具
用于找出 fumamx.com 实际使用的 API 端点
"""
import json
import requests
from typing import Dict, List
def test_endpoints(base_url: str = "https://www.fumamx.com", cookie_file: str = "/home/ubuntu/fumamx_cookies.txt"):
"""测试常见的企业系统 API 端点"""
# 加载 cookies
with open(cookie_file, 'r') as f:
cookies = {c['name']: c['value'] for c in json.load(f)}
session = requests.Session()
for name, value in cookies.items():
session.cookies.set(name, value)
headers = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'X-Requested-With': 'XMLHttpRequest',
}
# 常见的企业 CRM API 端点模式
test_paths = [
# 用户相关
"/api/v1/user/info",
"/api/user/info",
"/api/current-user",
"/api/account/info",
"/rest/account/info",
"/v1/api/user",
"/sys/user/info",
# 工作台
"/api/workbench/info",
"/api/v1/workbench",
"/api/dashboard/data",
"/rest/workbench",
# 客户
"/api/customer/list",
"/api/v1/customers",
"/api/crm/customers",
"/rest/customers",
"/crm/api/customers",
# 邮件
"/api/email/list",
"/api/v1/emails",
"/rest/emails",
# 订单
"/api/order/list",
"/api/v1/orders",
"/rest/orders",
"/api/trade/orders",
# 通知
"/api/notice/list",
"/api/notifications",
"/api/message/list",
# 统计
"/api/statistics/overview",
"/api/stats/summary",
"/api/report/dashboard",
# 通用
"/api/common/config",
"/api/system/config",
"/api/setting",
]
results = []
for path in test_paths:
url = base_url + path
try:
response = session.get(url, headers=headers, timeout=10)
status = response.status_code
content_type = response.headers.get('Content-Type', '')
if status == 200:
try:
data = response.json()
preview = str(data)[:100]
except:
preview = response.text[:100]
results.append({
"path": path,
"status": status,
"content_type": content_type,
"preview": preview,
"valid": True
})
print(f"✓ {path} - 200 OK")
elif status in [401, 403]:
results.append({
"path": path,
"status": status,
"valid": "auth_required"
})
print(f"⚠ {path} - {status} (可能需要认证)")
else:
print(f"✗ {path} - {status}")
except Exception as e:
print(f"✗ {path} - Error: {str(e)[:50]}")
# 保存结果
with open("/home/ubuntu/nanocode/fumamxCLI/api_endpoints.json", "w") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n发现 {len([r for r in results if r.get('valid') == True])} 个可用端点")
print("结果已保存到 api_endpoints.json")
return results
if __name__ == "__main__":
test_endpoints()
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
fumamx 认证模块 - 自动登录获取 Token
使用 Playwright 浏览器引擎完成 RSA 加密登录
"""
import asyncio
import json
import os
from pathlib import Path
from typing import Dict, Optional
# 登录凭据配置
USERNAME = 'showmin.zhou@163.com'
PASSWORD = 'Kingc.83695910'
# Token 缓存文件
TOKEN_CACHE_FILE = Path.home() / '.fumamx' / 'tokens.json'
class AuthError(Exception):
"""认证错误"""
pass
async def login_with_playwright(username: str = USERNAME, password: str = PASSWORD) -> Dict:
"""
使用 Playwright 浏览器自动登录 fumamx
Returns:
{
'success': bool,
'accessToken': str or None,
'individualAccessToken': str or None,
'cookies': dict,
'error': str or None
}
"""
try:
from playwright.async_api import async_playwright
except ImportError:
raise AuthError("Playwright 未安装,请运行: pip install playwright && playwright install chromium")
result = {
'success': False,
'accessToken': None,
'individualAccessToken': None,
'cookies': {},
'error': None,
}
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-dev-shm-usage']
)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
# 注入请求拦截脚本
await context.add_init_script("""
window._fumamx_data = {
payloads: [],
responses: {},
cookies: {}
};
// 拦截 XHR
const origXHRSend = XMLHttpRequest.prototype.send;
const origXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this._url = url;
this._method = method;
return origXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (body) {
try {
const data = JSON.parse(body);
if (data && data.pass && data.user) {
window._fumamx_data.payloads.push({url: this._url, payload: data});
}
} catch(e) {}
}
return origXHRSend.apply(this, arguments);
};
""")
page = await context.new_page()
# 监听响应捕获 token
login_response = {}
async def on_response(resp):
url = resp.url
if 'login/auth' in url or 'login' in url:
try:
body = await resp.json()
login_response['auth'] = body
except:
pass
elif 'codeForToken' in url:
try:
body = await resp.json()
login_response['token'] = body
except:
pass
page.on('response', on_response)
# 打开登录页
try:
await page.goto('https://fumamx.com', timeout=60000, wait_until='domcontentloaded')
except Exception as e:
result['error'] = f"页面加载失败: {e}"
await browser.close()
return result
await asyncio.sleep(3)
# 填写表单
try:
await page.fill('input[type="text"]', username)
await page.fill('input[type="password"]', password)
except Exception as e:
result['error'] = f"表单填写失败: {e}"
await browser.close()
return result
await asyncio.sleep(0.5)
# 提交登录
try:
await page.click('button:has-text("登录")')
except:
try:
await page.press('input[type="password"]', 'Enter')
except Exception as e:
result['error'] = f"登录提交失败: {e}"
await browser.close()
return result
# 等待登录完成
await asyncio.sleep(5)
# 检查是否登录成功
current_url = page.url
if 'login' in current_url or 'error' in current_url.lower():
result['error'] = "登录失败,请检查用户名和密码"
await browser.close()
return result
# 从响应中提取 token (备用方法)
if 'auth' in login_response:
auth_data = login_response['auth']
if auth_data.get('code') == '0' or auth_data.get('code') == 0:
result['success'] = True
data = auth_data.get('data', {})
access_token = data.get('accessToken')
if isinstance(access_token, dict):
result['accessToken'] = access_token.get('value')
elif isinstance(access_token, str):
result['accessToken'] = access_token
else:
result['error'] = f"登录失败: {auth_data.get('msg', '未知错误')}"
if 'token' in login_response:
token_data = login_response['token']
if token_data.get('code') == '0':
result['individualAccessToken'] = token_data.get('data', {}).get('value')
# 方法2: 直接从 sessionStorage 获取 (更可靠)
try:
session_data = await page.evaluate("""() => {
return {
accessToken: sessionStorage.getItem('accessToken'),
individualAccessToken: sessionStorage.getItem('individualAccessToken')
};
}""")
if session_data.get('accessToken'):
try:
at_data = json.loads(session_data['accessToken'])
if isinstance(at_data, dict):
result['accessToken'] = at_data.get('value')
else:
result['accessToken'] = str(at_data)
except:
result['accessToken'] = session_data['accessToken']
if session_data.get('individualAccessToken'):
try:
iat_data = json.loads(session_data['individualAccessToken'])
if isinstance(iat_data, dict):
result['individualAccessToken'] = iat_data.get('value')
else:
result['individualAccessToken'] = str(iat_data)
except:
result['individualAccessToken'] = session_data['individualAccessToken']
if session_data.get('accessToken') or session_data.get('individualAccessToken'):
result['success'] = True
except Exception as e:
print(f"[debug] sessionStorage 获取失败: {e}")
# 获取 cookies
cookies = await context.cookies()
for cookie in cookies:
result['cookies'][cookie['name']] = cookie['value']
await browser.close()
return result
def save_tokens(tokens: Dict) -> bool:
"""保存 token 到缓存文件"""
try:
TOKEN_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(TOKEN_CACHE_FILE, 'w') as f:
json.dump({
'accessToken': tokens.get('accessToken'),
'individualAccessToken': tokens.get('individualAccessToken'),
'cookies': tokens.get('cookies', {})
}, f, indent=2)
return True
except Exception as e:
print(f"保存 token 失败: {e}")
return False
def load_tokens() -> Optional[Dict]:
"""从缓存文件加载 token"""
try:
if not TOKEN_CACHE_FILE.exists():
return None
with open(TOKEN_CACHE_FILE, 'r') as f:
return json.load(f)
except Exception:
return None
def refresh_auth() -> Dict:
"""
刷新认证 - 使用 Playwright 重新登录
Returns:
包含新 token 的字典
"""
print("[auth] 正在使用 Playwright 自动登录...")
result = asyncio.run(login_with_playwright())
if result['success']:
save_tokens(result)
print(f"[auth] ✓ 登录成功")
print(f"[auth] accessToken: {result['accessToken'][:20]}...")
print(f"[auth] individualAccessToken: {result['individualAccessToken'][:20]}...")
else:
print(f"[auth] ✗ 登录失败: {result['error']}")
return result
def get_auth_headers() -> Dict[str, str]:
"""获取认证请求头"""
tokens = load_tokens()
if not tokens:
return {}
return {
'accessToken': tokens.get('accessToken', ''),
'individualAccessToken': tokens.get('individualAccessToken', ''),
}
if __name__ == "__main__":
# 测试登录
result = asyncio.run(login_with_playwright())
print(json.dumps(result, indent=2, ensure_ascii=False))
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env python3
"""
fumamxCLI - 孚盟MX 贸易数据命令行工具
基于抓取的实际 API 生成
"""
import json
import sys
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.json import JSON
from rich.tree import Tree
from .client import FumamxClient
from . import config
from .auth import refresh_auth, load_tokens
console = Console()
def get_client():
"""获取 API 客户端实例"""
cookie_file = "/home/ubuntu/fumamx_cookies.txt"
if not Path(cookie_file).exists():
console.print(f"[red]✗ Cookie 文件不存在: {cookie_file}[/red]")
sys.exit(1)
return FumamxClient(cookie_file)
@click.group()
@click.version_option(version="0.2.0")
def cli():
"""fumamxCLI - 孚盟MX 贸易数据命令行工具
使用已保存的 Cookie 访问 fumamx.com 贸易数据 API
主要功能:
- 公司搜索与详情查询
- 进口/出口企业数据分析
- 产品 HS 编码分析
- 贸易记录查询
"""
pass
# ============ 系统命令 ============
@cli.command("status")
def status():
"""检查登录状态和 API 连通性"""
try:
client = get_client()
# 测试公司搜索端点
result = client.search_company("test")
if result.get("code") == "0":
console.print("[green]✓ API 连接正常,认证有效[/green]")
console.print(f"[dim]用户: {client.cookies.get('user', 'N/A')}[/dim]")
console.print(f"[dim]公司ID: {config.COMPANY_ID}[/dim]")
else:
console.print(f"[yellow]⚠ API 响应异常: {result}[/yellow]")
except Exception as e:
console.print(f"[red]✗ 连接失败: {e}[/red]")
console.print("[yellow]提示: 尝试运行 'fumamx login' 重新获取认证[/yellow]")
@cli.command("login")
def login_cmd():
"""使用 Playwright 自动登录获取 Token
当 Cookie 文件或缓存的 Token 失效时,使用此命令重新登录。
会自动完成 RSA 加密并获取新的 accessToken 和 individualAccessToken。
"""
from .auth import USERNAME, PASSWORD, refresh_auth
console.print("[cyan]正在使用 Playwright 自动登录...[/cyan]")
console.print(f"[dim]账号: {USERNAME}[/dim]")
try:
# refresh_auth 已经内部处理了 asyncio.run
result = refresh_auth()
if result['success']:
console.print("[green]✓ 登录成功[/green]")
access_token = result.get('accessToken', '')
individual_token = result.get('individualAccessToken', '')
console.print(f"[dim]accessToken: {access_token[:20] if access_token else 'N/A'}...[/dim]")
console.print(f"[dim]individualAccessToken: {individual_token[:20] if individual_token else 'N/A'}...[/dim]")
# 验证认证
console.print("[cyan]正在验证 API 连接...[/cyan]")
client = FumamxClient(auto_login=False)
if client.check_auth():
console.print("[green]✓ API 连接正常,认证有效[/green]")
else:
console.print("[yellow]⚠ 登录成功但 API 验证失败[/yellow]")
else:
console.print(f"[red]✗ 登录失败: {result.get('error', '未知错误')}[/red]")
except Exception as e:
console.print(f"[red]✗ 登录出错: {e}[/red]")
console.print("[yellow]提示: 确保已安装 Playwright[/yellow]")
console.print(" pip install playwright && playwright install chromium")
@cli.command("cookies")
def show_cookies():
"""显示已加载的 cookies"""
cookie_file = "/home/ubuntu/fumamx_cookies.txt"
# 先显示缓存的 tokens
tokens = load_tokens()
if tokens:
console.print("[cyan]缓存的 Tokens:[/cyan]")
console.print(f" accessToken: {tokens.get('accessToken', 'N/A')[:30]}...")
console.print(f" individualAccessToken: {tokens.get('individualAccessToken', 'N/A')[:30]}...")
console.print()
if not Path(cookie_file).exists():
console.print(f"[yellow]Cookie 文件不存在: {cookie_file}[/yellow]")
return
with open(cookie_file, 'r') as f:
cookies = json.load(f)
table = Table(title="已加载的 Cookies")
table.add_column("名称", style="cyan")
table.add_column("值", style="green")
table.add_column("域名", style="yellow")
for cookie in cookies:
value = cookie['value'][:40] + "..." if len(cookie['value']) > 40 else cookie['value']
table.add_row(cookie['name'], value, cookie.get('domain', 'N/A'))
console.print(table)
# ============ 公司搜索命令 ============
@cli.group()
def company():
"""公司搜索与查询"""
pass
@company.command("search")
@click.argument("keyword")
@click.option("--page", "-p", default=1, help="页码")
@click.option("--page-size", "-s", default=10, help="每页数量")
@click.option("--raw", is_flag=True, help="直接输出原始 JSON")
def company_search(keyword, page, page_size, raw):
"""搜索公司/企业
示例: fumamx company search "Geomatrix"
"""
client = get_client()
try:
data = client.search_company(keyword, page, page_size)
if raw:
# 直接输出 JSON,供程序解析
click.echo(json.dumps(data, ensure_ascii=False))
else:
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"公司搜索: {keyword} (第{page}页)"
))
except Exception as e:
console.print(f"[red]✗ 搜索失败: {e}[/red]")
@company.command("info")
@click.argument("company_id")
@click.argument("domain")
@click.option("--raw", is_flag=True, help="直接输出原始 JSON")
def company_info(company_id, domain, raw):
"""获取公司详细信息
示例: fumamx company info "f35246ed..." "vectorgeo.co"
包含: 地址、电话、员工数、行业、LinkedIn 等信息
"""
client = get_client()
try:
data = client.get_company_info(company_id, domain)
if raw:
click.echo(json.dumps(data, ensure_ascii=False))
else:
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"公司详情: {domain}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
@company.command("contacts")
@click.argument("company_id")
@click.argument("domain")
@click.option("--raw", is_flag=True, help="直接输出原始 JSON")
@click.option("--limit", "-l", default=10, help="显示联系人数量限制")
def company_contacts(company_id, domain, raw, limit):
"""获取公司联系人列表
示例: fumamx company contacts "f35246ed..." "vectorgeo.co"
包含: 姓名、邮箱、职位、LinkedIn、电话等
"""
client = get_client()
try:
data = client.get_company_contacts(company_id, domain)
if raw:
click.echo(json.dumps(data, ensure_ascii=False))
else:
# 格式化输出联系人列表
if data.get("code") == 0 and data.get("data", {}).get("list"):
contacts = data["data"]["list"][:limit]
table = Table(title=f"联系人列表: {domain}")
table.add_column("姓名", style="cyan")
table.add_column("职位", style="green")
table.add_column("邮箱", style="yellow")
table.add_column("电话", style="red")
table.add_column("LinkedIn", style="blue")
for c in contacts:
table.add_row(
c.get("name") or "-",
c.get("position") or "-",
c.get("email") or "-",
c.get("phone") or c.get("realWhatsApp") or "-",
"✓" if c.get("linkedin") else "-"
)
console.print(table)
console.print(f"\n[dim]总计: {data['data'].get('workerNumber', 0)} 人, "
f"高管: {data['data'].get('executiveCount', 0)} 人, "
f"有电话: {data['data'].get('phoneCount', 0)} 人[/dim]")
else:
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"联系人: {domain}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
@company.command("phones")
@click.argument("domain")
@click.option("--raw", is_flag=True, help="直接输出原始 JSON")
def company_phones(domain, raw):
"""获取公司电话/图片信息
示例: fumamx company phones "vectorgeo.co"
通过爬虫抓取的电话、图片等信息
"""
client = get_client()
try:
data = client.get_company_phones(domain)
if raw:
click.echo(json.dumps(data, ensure_ascii=False))
else:
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"电话/图片信息: {domain}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
# ============ 进口数据命令 ============
@cli.group()
def import_():
"""进口企业数据分析"""
pass
@import_.command("enterprise")
@click.argument("company_name")
@click.option("--country", "-c", default="", help="国家筛选")
@click.option("--page", "-p", default=1, help="页码")
@click.option("--page-size", "-s", default=20, help="每页数量")
def import_enterprise(company_name, country, page, page_size):
"""获取进口企业数据
示例: fumamx import enterprise "Apple Inc" --country "US"
"""
client = get_client()
try:
data = client.import_enterprise(company_name, country)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"进口企业: {company_name}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
@import_.command("stats")
@click.argument("company_name")
def import_stats(company_name):
"""获取进口企业统计数据
示例: fumamx import stats "Apple Inc"
"""
client = get_client()
try:
data = client.import_enterprise_stats(company_name)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"进口统计: {company_name}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
# ============ 出口数据命令 ============
@cli.group()
def export():
"""出口企业数据分析"""
pass
@export.command("enterprise")
@click.argument("company_name")
@click.option("--country", "-c", default="", help="国家筛选")
@click.option("--page", "-p", default=1, help="页码")
@click.option("--page-size", "-s", default=20, help="每页数量")
def export_enterprise(company_name, country, page, page_size):
"""获取出口企业数据
示例: fumamx export enterprise "Foxconn" --country "CN"
"""
client = get_client()
try:
data = client.export_enterprise(company_name, country)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"出口企业: {company_name}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
@export.command("stats")
@click.argument("company_name")
def export_stats(company_name):
"""获取出口企业统计数据
示例: fumamx export stats "Foxconn"
"""
client = get_client()
try:
data = client.export_enterprise_stats(company_name)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"出口统计: {company_name}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
# ============ 产品分析命令 ============
@cli.group()
def product():
"""产品数据分析"""
pass
@product.command("hs-code")
@click.argument("hs_code")
def product_hs_code(hs_code):
"""HS 编码分析
示例: fumamx product hs-code "8517.12.00"
"""
client = get_client()
try:
data = client.hs_code_analysis(hs_code)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"HS 编码分析: {hs_code}"
))
except Exception as e:
console.print(f"[red]✗ 分析失败: {e}[/red]")
@product.command("agg")
@click.argument("company_name")
def product_agg(company_name):
"""获取公司产品聚合数据
示例: fumamx product agg "Apple Inc"
"""
client = get_client()
try:
data = client.company_agg_data(company_name)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"产品聚合: {company_name}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
@product.command("analysis")
@click.argument("company_name")
def product_analysis(company_name):
"""出口产品分析
示例: fumamx product analysis "Foxconn"
"""
client = get_client()
try:
data = client.export_enterprise_analysis(company_name)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"产品分析: {company_name}"
))
except Exception as e:
console.print(f"[red]✗ 分析失败: {e}[/red]")
@product.command("compare-lose")
@click.argument("company_name")
@click.argument("compare_company")
def product_compare_lose(company_name, compare_company):
"""出口对比流失分析
示例: fumamx product compare-lose "Company A" "Company B"
"""
client = get_client()
try:
data = client.export_compare_lose_analysis(company_name, compare_company)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"流失分析: {company_name} vs {compare_company}"
))
except Exception as e:
console.print(f"[red]✗ 分析失败: {e}[/red]")
@product.command("compare-new")
@click.argument("company_name")
@click.argument("compare_company")
def product_compare_new(company_name, compare_company):
"""出口对比新增分析
示例: fumamx product compare-new "Company A" "Company B"
"""
client = get_client()
try:
data = client.export_compare_new_analysis(company_name, compare_company)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"新增分析: {company_name} vs {compare_company}"
))
except Exception as e:
console.print(f"[red]✗ 分析失败: {e}[/red]")
# ============ 国家分析命令 ============
@cli.group()
def country():
"""国家贸易数据分析"""
pass
@country.command("export")
@click.argument("country_name")
def country_export(country_name):
"""出口国家分析
示例: fumamx country export "United States"
"""
client = get_client()
try:
data = client.export_country_analysis(country_name)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"国家出口分析: {country_name}"
))
except Exception as e:
console.print(f"[red]✗ 分析失败: {e}[/red]")
@country.command("data")
@click.argument("country_name")
def country_data(country_name):
"""国家贸易数据详情
示例: fumamx country data "China"
"""
client = get_client()
try:
data = client.country_analysis_data(country_name)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"国家数据: {country_name}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
@country.command("list")
def country_list():
"""获取贸易来源国家列表"""
client = get_client()
try:
data = client.get_trade_source_countries()
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title="贸易来源国家"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
# ============ 贸易记录命令 ============
@cli.group()
def trade():
"""贸易记录查询"""
pass
@trade.command("detail")
@click.argument("trade_no")
@click.option("--country", "-c", default="", help="来源国家")
@click.option("--date", "-d", default="", help="注册日期")
@click.option("--type", "-t", default="", help="进出口类型")
def trade_detail(trade_no, country, date, type):
"""获取完整贸易详情
示例: fumamx trade detail "TRADE2024001" --country "US"
"""
client = get_client()
try:
data = client.trade_detail_full(trade_no, country, date, type)
console.print(Panel.fit(
JSON(json.dumps(data, ensure_ascii=False, indent=2)),
title=f"贸易详情: {trade_no}"
))
except Exception as e:
console.print(f"[red]✗ 获取失败: {e}[/red]")
# ============ 通用 API 调用命令 ============
@cli.command("call")
@click.argument("method")
@click.argument("path")
@click.option("--data", "-d", help="POST/PUT 数据 (JSON 格式)")
@click.option("--params", "-p", help="查询参数 (JSON 格式)")
def api_call(method, path, data, params):
"""调用任意 API 端点
示例:
fumamx call GET /pcapi/tradeData/api/V2/Account/GetTradeSourceCountrys
fumamx call POST /pcapi/tradeData/api/V2/TradeCompany/ImpEnterprise -d '{"companyName":"Apple"}'
"""
client = get_client()
request_data = json.loads(data) if data else None
request_params = json.loads(params) if params else None
try:
result = client.call_api(method, path, request_data, request_params)
console.print(Panel.fit(
JSON(json.dumps(result, ensure_ascii=False, indent=2)),
title=f"{method} {path}"
))
except Exception as e:
console.print(f"[red]✗ 调用失败: {e}[/red]")
@cli.command("list-apis")
def list_apis():
"""列出所有可用的 API 命令"""
tree = Tree("[bold cyan]fumamxCLI 可用命令[/bold cyan]")
sys_node = tree.add("[yellow]系统命令[/yellow]")
sys_node.add("status - 检查登录状态")
sys_node.add("cookies - 显示已加载的 cookies")
sys_node.add("call - 调用任意 API 端点")
company_node = tree.add("[green]公司查询 (company)[/green]")
company_node.add("search <keyword> - 搜索公司")
company_node.add("info <id> <domain> - 公司详细信息")
company_node.add("contacts <id> <domain> - 联系人列表")
company_node.add("phones <domain> - 电话/图片信息")
import_node = tree.add("[blue]进口数据 (import)[/blue]")
import_node.add("enterprise <name> - 进口企业数据")
import_node.add("stats <name> - 进口统计")
export_node = tree.add("[magenta]出口数据 (export)[/magenta]")
export_node.add("enterprise <name> - 出口企业数据")
export_node.add("stats <name> - 出口统计")
product_node = tree.add("[cyan]产品分析 (product)[/cyan]")
product_node.add("hs-code <code> - HS 编码分析")
product_node.add("agg <name> - 公司产品聚合")
product_node.add("analysis <name> - 产品分析")
product_node.add("compare-lose <a> <b> - 流失对比")
product_node.add("compare-new <a> <b> - 新增对比")
country_node = tree.add("[yellow]国家分析 (country)[/yellow]")
country_node.add("export <name> - 出口国家分析")
country_node.add("data <name> - 国家贸易数据")
country_node.add("list - 贸易来源国家列表")
trade_node = tree.add("[red]贸易记录 (trade)[/red]")
trade_node.add("detail <no> - 贸易详情")
console.print(tree)
if __name__ == "__main__":
cli()
+493
View File
@@ -0,0 +1,493 @@
#!/usr/bin/env python3
"""
Fumamx API Client - 基于抓取的实际 API 生成
支持自动登录刷新 Token
"""
import json
import requests
import time
from pathlib import Path
from typing import Dict, Optional, List, Any
from urllib.parse import urljoin
from . import config
from .auth import refresh_auth, load_tokens, AuthError
class FumamxClient:
"""孚盟MX API 客户端 - 贸易数据模块
认证优先级:
1. 使用传入的 cookie_file 中的 cookies
2. 使用缓存的 tokens (通过 Playwright 登录获得)
3. 自动触发 Playwright 登录获取新 tokens
"""
BASE_URL = "https://www.fumamx.com"
def __init__(self, cookie_file: str = "/home/ubuntu/fumamx_cookies.txt", auto_login: bool = True):
self.cookie_file = cookie_file
self.session = requests.Session()
self.cookies = {}
self.common_params = {}
self.auto_login = auto_login
self._auth_refreshed = False
# 尝试加载认证信息
if not self._load_cookies() and auto_login:
# Cookie 加载失败,尝试使用缓存的 token
if not self._load_cached_tokens():
# 缓存也没有,触发自动登录
self._do_auto_login()
self._init_common_params()
def _load_cookies(self) -> bool:
"""从 JSON 文件加载 cookies
Returns:
bool: 是否成功加载
"""
try:
if not Path(self.cookie_file).exists():
return False
with open(self.cookie_file, 'r') as f:
cookie_list = json.load(f)
for cookie in cookie_list:
name = cookie.get('name')
value = cookie.get('value')
if name and value:
self.cookies[name] = value
self.session.cookies.set(name, value)
print(f"✓ 已加载 {len(self.cookies)} 个 cookies")
return True
except Exception as e:
print(f"⚠ 加载 cookies 失败: {e}")
return False
def _load_cached_tokens(self) -> bool:
"""从缓存加载 tokens 和 cookies
Returns:
bool: 是否成功加载
"""
tokens = load_tokens()
if not tokens:
return False
has_tokens = tokens.get('accessToken') and tokens.get('individualAccessToken')
if has_tokens:
print("✓ 使用缓存的 tokens")
# 同时加载缓存的 cookies (如 _t)
cached_cookies = tokens.get('cookies', {})
for name, value in cached_cookies.items():
self.cookies[name] = value
self.session.cookies.set(name, value)
if cached_cookies:
print(f"✓ 已加载 {len(cached_cookies)} 个缓存 cookies")
return has_tokens
def _do_auto_login(self) -> bool:
"""触发自动登录获取新 tokens 和 cookies
Returns:
bool: 是否登录成功
"""
if self._auth_refreshed:
return False # 避免循环刷新
try:
print("⚠ Cookie/Token 无效,正在自动登录...")
result = refresh_auth()
if result['success']:
self._auth_refreshed = True
# 加载登录获得的 cookies 到 session
login_cookies = result.get('cookies', {})
for name, value in login_cookies.items():
self.cookies[name] = value
self.session.cookies.set(name, value)
if login_cookies:
print(f"✓ 已加载 {len(login_cookies)} 个登录 cookies")
# 重新加载 tokens
self._load_cached_tokens()
return True
else:
print(f"✗ 自动登录失败: {result.get('error')}")
return False
except AuthError as e:
print(f"✗ 自动登录需要 Playwright: {e}")
print(" 请运行: pip install playwright && playwright install chromium")
return False
except Exception as e:
print(f"✗ 自动登录出错: {e}")
return False
def _init_common_params(self):
"""初始化公共参数(优先使用缓存/自动登录获取的 token)"""
# 优先使用缓存的 tokens
tokens = load_tokens()
access_token = tokens.get('accessToken') if tokens else None
individual_token = tokens.get('individualAccessToken') if tokens else None
# 如果没有缓存的,使用配置文件中的
if not access_token:
access_token = config.ACCESS_TOKEN
if not individual_token:
individual_token = config.INDIVIDUAL_ACCESS_TOKEN
self.common_params = {
'accessToken': access_token,
'individualAccessToken': individual_token,
'_filterCompanyId': config.COMPANY_ID,
'isV5': 'true',
'isV5AndNewMail': 'true',
'isNewArchCompany': 'true',
'languageForMX5': 'zh-cn',
'operating_terminal': 'pc_网页端',
'event_source': 'MX',
}
def _headers(self) -> Dict[str, str]:
"""构造请求头"""
return {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Content-Type': 'application/json;charset=UTF-8',
'Origin': self.BASE_URL,
'Referer': f"{self.BASE_URL}/",
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'X-Requested-With': 'XMLHttpRequest',
}
def _build_url(self, path: str) -> str:
"""构建完整 URL"""
if path.startswith('http'):
return path
return f"{self.BASE_URL}{path}"
def _merge_params(self, extra_params: Optional[Dict] = None) -> Dict:
"""合并公共参数和额外参数"""
params = self.common_params.copy()
params['_@#time'] = int(time.time() * 1000)
if extra_params:
params.update(extra_params)
# 过滤 None 值
return {k: v for k, v in params.items() if v is not None}
def get(self, path: str, params: Optional[Dict] = None) -> Dict:
"""GET 请求"""
url = self._build_url(path)
merged_params = self._merge_params(params)
response = self.session.get(
url,
headers=self._headers(),
params=merged_params,
timeout=30
)
response.raise_for_status()
return response.json()
def post(self, path: str, data: Optional[Dict] = None) -> Dict:
"""POST 请求"""
url = self._build_url(path)
merged_params = self._merge_params()
response = self.session.post(
url,
headers=self._headers(),
params=merged_params,
json=data,
timeout=30
)
response.raise_for_status()
return response.json()
# ============ 贸易数据 - 公司搜索 ============
def search_company(self, keyword: str, page: int = 1, page_size: int = 10) -> Dict:
"""搜索公司/企业
对应端点: POST /pcapi/findServer/standard/company
"""
data = {
"selectedKey": [],
"searchType": "all",
"activeType": "company",
"inputValue": keyword,
"keyword": [keyword],
"FyStr": keyword,
"productFy": [keyword],
"currentValue": "input",
"language": ["en", "ru", "pt", "spa"],
"selectCountry": [],
"searchMode": 0,
"country": [],
"industry": [],
"employeesLevel": [],
"founded": [],
"priceRange": [],
"filterCust": 0,
"filterView": 0,
"excludeGovernment": False,
"excludeSchool": False,
"excludeForwarder": False,
"excludeBank": False,
"excludeCn": True,
"page": page,
"size": page_size
}
return self.post("/pcapi/findServer/standard/company", data)
# ============ 贸易数据 - 进口企业 ============
def import_enterprise(self, company_name: str, country: str = "") -> Dict:
"""获取进口企业数据
对应端点: POST /pcapi/tradeData/api/V2/TradeCompany/ImpEnterprise
"""
data = {
"companyName": company_name,
"country": country,
"page": 1,
"pageSize": 20
}
return self.post("/pcapi/tradeData/api/V2/TradeCompany/ImpEnterprise", data)
def import_enterprise_stats(self, company_name: str) -> Dict:
"""获取进口企业统计
对应端点: POST /pcapi/tradeData/api/V1/TradeCompany/ImpEnterpriseStatistics
"""
data = {"companyName": company_name}
return self.post("/pcapi/tradeData/api/V1/TradeCompany/ImpEnterpriseStatistics", data)
# ============ 贸易数据 - 出口企业 ============
def export_enterprise(self, company_name: str, country: str = "") -> Dict:
"""获取出口企业数据
对应端点: POST /pcapi/tradeData/api/V2/TradeCompany/ExpEnterprise
"""
data = {
"companyName": company_name,
"country": country,
"page": 1,
"pageSize": 20
}
return self.post("/pcapi/tradeData/api/V2/TradeCompany/ExpEnterprise", data)
def export_enterprise_stats(self, company_name: str) -> Dict:
"""获取出口企业统计
对应端点: POST /pcapi/tradeData/api/V1/TradeCompany/ExpEnterpriseStatistics
"""
data = {"companyName": company_name}
return self.post("/pcapi/tradeData/api/V1/TradeCompany/ExpEnterpriseStatistics", data)
# ============ 贸易数据 - 产品分析 ============
def company_agg_data(self, company_name: str) -> Dict:
"""获取公司聚合数据
对应端点: POST /pcapi/tradeData/api/V2/TradeProduct/getCompanyAggData
"""
data = {"companyName": company_name}
return self.post("/pcapi/tradeData/api/V2/TradeProduct/getCompanyAggData", data)
def hs_code_analysis(self, hs_code: str) -> Dict:
"""HS 编码分析
对应端点: POST /pcapi/tradeData/api/V2/TradeProduct/HsCodeAnalysis
"""
data = {"hsCode": hs_code}
return self.post("/pcapi/tradeData/api/V2/TradeProduct/HsCodeAnalysis", data)
def export_enterprise_analysis(self, company_name: str) -> Dict:
"""出口企业分析
对应端点: POST /pcapi/tradeData/api/V2/TradeProduct/ExpEnterpriseAnalysis
"""
data = {"companyName": company_name}
return self.post("/pcapi/tradeData/api/V2/TradeProduct/ExpEnterpriseAnalysis", data)
def company_analysis_agg(self, company_name: str) -> Dict:
"""公司分析聚合数据
对应端点: POST /pcapi/tradeData/api/V2/TradeProduct/getCompanyAnalysisAggData
"""
data = {"companyName": company_name}
return self.post("/pcapi/tradeData/api/V2/TradeProduct/getCompanyAnalysisAggData", data)
def export_compare_lose_analysis(self, company_name: str, compare_company: str) -> Dict:
"""出口对比流失分析
对应端点: POST /pcapi/tradeData//api/V2/TradeProduct/ExpEnterpriseCompareLoseAnalysis
"""
data = {
"companyName": company_name,
"compareCompany": compare_company
}
return self.post("/pcapi/tradeData//api/V2/TradeProduct/ExpEnterpriseCompareLoseAnalysis", data)
def export_compare_new_analysis(self, company_name: str, compare_company: str) -> Dict:
"""出口对比新增分析
对应端点: POST /pcapi/tradeData//api/V2/TradeProduct/ExpEnterpriseCompareNewAnalysis
"""
data = {
"companyName": company_name,
"compareCompany": compare_company
}
return self.post("/pcapi/tradeData//api/V2/TradeProduct/ExpEnterpriseCompareNewAnalysis", data)
def export_country_analysis(self, country: str) -> Dict:
"""出口国家分析
对应端点: POST /pcapi/tradeData/api/V2/TradeProduct/ExpCountryAnalysis
"""
data = {"country": country}
return self.post("/pcapi/tradeData/api/V2/TradeProduct/ExpCountryAnalysis", data)
def country_analysis_data(self, country: str) -> Dict:
"""国家分析数据
对应端点: POST /pcapi/tradeData/api/V2/TradeProduct/getCountryAnalysisData
"""
data = {"country": country}
return self.post("/pcapi/tradeData/api/V2/TradeProduct/getCountryAnalysisData", data)
# ============ 贸易详情 ============
def trade_detail_full(self, trade_no: str, source_country: str = "", reg_date: str = "", in_out: str = "") -> Dict:
"""获取完整贸易详情
对应端点: GET /pcapi/tradeData/api/V2/Trade/TradeDetailFull
"""
return self.get("/pcapi/tradeData/api/V2/Trade/TradeDetailFull", {
"tradeNo": trade_no,
"sourceCountry": source_country,
"regDate": reg_date,
"inOut": in_out,
"sourceType": "1"
})
# ============ 用户/账户 ============
def get_trade_source_countries(self) -> Dict:
"""获取贸易来源国家列表
对应端点: GET /pcapi/tradeData/api/V2/Account/GetTradeSourceCountrys
"""
return self.get("/pcapi/tradeData/api/V2/Account/GetTradeSourceCountrys")
# ============ 企业详情 API ============
def get_company_info(self, company_id: str, domain: str) -> Dict:
"""获取公司详细信息
对应端点: GET /pcapi/findServer/find/getMxCompanyInfo
包含: 地址、电话、员工数、行业等信息
"""
params = {
"id": company_id,
"domain": domain,
}
return self.get("/pcapi/findServer/find/getMxCompanyInfo", params)
def get_company_contacts(self, company_id: str, domain: str) -> Dict:
"""获取公司联系人列表
对应端点: GET /pcapi/findServer/find/mxStaffList
包含: 姓名、邮箱、职位、LinkedIn、电话等
"""
params = {
"id": company_id,
"domain": domain,
}
return self.get("/pcapi/findServer/find/mxStaffList", params)
def get_company_phones(self, domain: str) -> Dict:
"""获取公司电话/图片信息
对应端点: GET /pcapi/findServer/find/spiderDomainImage
包含: 电话、图片等爬虫抓取的信息
"""
params = {
"domain": domain,
}
return self.get("/pcapi/findServer/find/spiderDomainImage", params)
# ============ 其他 ============
def wap_label_list(self, wid: str = "330204727296") -> Dict:
"""获取标签列表
对应端点: GET /pcapi/v1/waplabellist
"""
return self.get("/pcapi/v1/waplabellist", {
"wid": wid,
"format": "mx"
})
def check_sse_exist(self) -> Dict:
"""检查 SSE 是否存在
对应端点: GET /v1/isSseExist
"""
return self.get("/v1/isSseExist")
# ============ 通用 API 探测 ============
def probe_api(self, endpoint: str) -> Dict:
"""探测任意 API 端点"""
try:
return self.get(endpoint)
except Exception as e:
return {"error": str(e), "endpoint": endpoint}
def call_api(self, method: str, path: str, data: Optional[Dict] = None, params: Optional[Dict] = None) -> Dict:
"""通用 API 调用"""
if method.upper() == "GET":
return self.get(path, params)
elif method.upper() == "POST":
return self.post(path, data)
else:
raise ValueError(f"不支持的 HTTP 方法: {method}")
def check_auth(self) -> bool:
"""检查当前认证是否有效
Returns:
bool: 认证是否有效
"""
try:
result = self.search_company("test", page=1, page_size=1)
return result.get("code") == "0"
except Exception:
return False
def refresh_if_needed(self) -> bool:
"""如果需要,刷新认证
Returns:
bool: 刷新后认证是否有效
"""
if self.check_auth():
return True
if not self.auto_login:
return False
print("⚠ 认证失效,尝试自动刷新...")
if self._do_auto_login():
self._init_common_params() # 重新初始化参数
return self.check_auth()
return False
+12
View File
@@ -0,0 +1,12 @@
"""
fumamxCLI 配置文件
从抓取的 API 中提取的认证信息
"""
# API 认证令牌 (从浏览器抓取获得)
ACCESS_TOKEN = "wxUqXNKY4gacVh0c"
INDIVIDUAL_ACCESS_TOKEN = "0p4I8gwiwrwpW906"
COMPANY_ID = "1067743"
# 用户 ID (从 cookie 中获得)
USER_EMAIL = "showmin.zhou@163.com"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
动态 API 调用工具 - 支持自定义端点
"""
import json
import requests
from typing import Dict, Optional
class DynamicAPI:
"""动态 API 调用器"""
def __init__(self, cookie_file: str = "/home/ubuntu/fumamx_cookies.txt"):
self.cookie_file = cookie_file
self.session = requests.Session()
self.base_url = "https://www.fumamx.com"
self._load_cookies()
def _load_cookies(self):
"""加载 cookies"""
with open(self.cookie_file, 'r') as f:
cookies = json.load(f)
for c in cookies:
self.session.cookies.set(c['name'], c['value'])
def call(self, method: str, path: str, **kwargs) -> Dict:
"""调用任意 API"""
url = self.base_url + path if not path.startswith('http') else path
headers = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'X-Requested-With': 'XMLHttpRequest',
}
if 'headers' in kwargs:
headers.update(kwargs.pop('headers'))
try:
response = self.session.request(
method.upper(),
url,
headers=headers,
timeout=30,
**kwargs
)
return {
"status": response.status_code,
"url": response.url,
"headers": dict(response.headers),
"content": response.text[:1000] if not response.headers.get('Content-Type', '').startswith('application/json') else response.json()
}
except Exception as e:
return {"error": str(e)}
def interactive_mode():
"""交互式 API 测试模式"""
api = DynamicAPI()
print("fumamx 动态 API 测试工具")
print("示例: GET /api/user/info")
print(" POST /api/login data={}")
print("输入 'quit' 退出\n")
while True:
try:
cmd = input("> ").strip()
if cmd.lower() in ['quit', 'exit', 'q']:
break
parts = cmd.split()
if len(parts) < 2:
print("格式: METHOD /path [data={}]")
continue
method = parts[0].upper()
path = parts[1]
kwargs = {}
for part in parts[2:]:
if part.startswith('data='):
kwargs['json'] = json.loads(part[5:])
elif part.startswith('params='):
kwargs['params'] = json.loads(part[7:])
result = api.call(method, path, **kwargs)
print(json.dumps(result, ensure_ascii=False, indent=2))
except KeyboardInterrupt:
break
except Exception as e:
print(f"错误: {e}")
print("\n再见!")
if __name__ == "__main__":
interactive_mode()