feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
Executable
+327
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* index.ts — Skill 主入口
|
||||
*
|
||||
* 支持命令:
|
||||
* run() — 个股分析(主流程)
|
||||
* runDividend() — 股息分析
|
||||
* runRumorScan() — 传闻扫描
|
||||
* runWatchlistAdd() — 添加自选股
|
||||
* runWatchlistList() — 查看自选股
|
||||
* runWatchlistCheck()— 检查提醒
|
||||
* runWatchlistRemove()— 删除自选股
|
||||
* runChartAnalysis() — K线图分析
|
||||
*/
|
||||
|
||||
import ZAI from "z-ai-web-dev-sdk";
|
||||
import { fetchMultipleStocks, fetchMarketOverview, fetchGlobalMacro } from "./dataFetcher";
|
||||
import { analyzeMultipleStocks, analyzeChartImage } from "./analyzer";
|
||||
import { analyzeDividends, formatDividendMarkdown } from "./dividend";
|
||||
import { scanRumors, formatRumorMarkdown } from "./rumorScanner";
|
||||
import {
|
||||
addToWatchlist, removeFromWatchlist,
|
||||
listWatchlist, checkAlerts,
|
||||
formatWatchlistMarkdown, formatAlertsMarkdown,
|
||||
} from "./watchlist";
|
||||
import { SkillInput, StockInput, OutputFormat, AnalysisResult, PositionInfo, Verdict } from "./types";
|
||||
|
||||
// ── 输入解析 ──────────────────────────────────────────────
|
||||
|
||||
function parseInput(raw: unknown): {
|
||||
stocks: string[];
|
||||
positions: Record<string, PositionInfo>;
|
||||
outputFormat: OutputFormat;
|
||||
mode: "full" | "quote";
|
||||
includeMarketReview: boolean;
|
||||
includeGlobalMacro: boolean;
|
||||
includeDividend: boolean;
|
||||
} {
|
||||
const stocks: string[] = [];
|
||||
const positions: Record<string, PositionInfo> = {};
|
||||
let outputFormat: OutputFormat = "markdown";
|
||||
let mode: "full" | "quote" = "full";
|
||||
let includeMarketReview = false;
|
||||
let includeGlobalMacro = true;
|
||||
let includeDividend = false;
|
||||
|
||||
if (typeof raw === "string") {
|
||||
raw.split(/[,,\s]+/).map((s) => s.trim().toUpperCase()).filter(Boolean).forEach((c) => stocks.push(c));
|
||||
} else if (typeof raw === "object" && raw !== null) {
|
||||
const input = raw as any;
|
||||
const rawStocks: (string | StockInput)[] = input.stocks ?? (input.stock ? [input.stock] : []);
|
||||
for (const s of rawStocks) {
|
||||
if (typeof s === "string") stocks.push(s.trim().toUpperCase());
|
||||
else if (s.code) {
|
||||
const code = s.code.trim().toUpperCase();
|
||||
stocks.push(code);
|
||||
if (s.position) positions[code] = s.position;
|
||||
}
|
||||
}
|
||||
outputFormat = input.outputFormat ?? input.format ?? "markdown";
|
||||
mode = input.mode ?? "full";
|
||||
includeMarketReview = input.includeMarketReview ?? false;
|
||||
includeGlobalMacro = input.includeGlobalMacro ?? true;
|
||||
includeDividend = input.includeDividend ?? false;
|
||||
}
|
||||
|
||||
return { stocks, positions, outputFormat, mode, includeMarketReview, includeGlobalMacro, includeDividend };
|
||||
}
|
||||
|
||||
function log(msg: string) { console.log(`[${new Date().toISOString()}] ${msg}`); }
|
||||
|
||||
// ── 报告组装 ──────────────────────────────────────────────
|
||||
|
||||
function buildFullReport(
|
||||
results: AnalysisResult[],
|
||||
globalMacro?: string,
|
||||
marketOverview?: string
|
||||
): string {
|
||||
const date = new Date().toLocaleDateString("zh-CN");
|
||||
const buy = results.filter((r) => ["买入", "强烈买入"].includes(r.verdict)).length;
|
||||
const watch = results.filter((r) => r.verdict === "观望").length;
|
||||
const sell = results.filter((r) => r.verdict === "卖出").length;
|
||||
|
||||
let output = `# 📈 股票智能分析报告
|
||||
**生成时间:** ${date} | **分析 ${results.length} 只** | 🟢买入/强烈买入 ${buy} 🟡观望 ${watch} 🔴卖出 ${sell}
|
||||
|
||||
`;
|
||||
if (globalMacro) output += `---\n\n## 🌍 全球宏观速览\n\n${globalMacro}\n\n`;
|
||||
if (marketOverview) output += `---\n\n## 🎯 大盘复盘\n\n${marketOverview}\n\n`;
|
||||
output += `---\n\n## 📊 个股决策仪表盘\n\n`;
|
||||
output += results.map((r) => {
|
||||
const warn = r.warnings.length > 0 ? `\n> ⚠️ **预警:** ${r.warnings.join(" | ")}\n` : "";
|
||||
return `${warn}\n${r.analysis}\n\n---\n`;
|
||||
}).join("\n");
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ── 调用 pdf/docx skill ───────────────────────────────────
|
||||
|
||||
async function exportToFormat(content: string, format: "pdf" | "word"): Promise<string> {
|
||||
const zai = await ZAI.create();
|
||||
const isPDF = format === "pdf";
|
||||
const completion = await zai.chat.completions.create({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: isPDF
|
||||
? `请创建一份 PDF 文档,内容是以下股票研报。要求:A4页面,中文字体,每只股票独立分页,结论用颜色标注,末尾附免责声明。\n\n${content}`
|
||||
: `请创建一份 Word (.docx) 文档,内容是以下股票研报。要求:保留标题层级,每只股票独立分页,作战计划用表格,末尾附免责声明。\n\n${content}`,
|
||||
}],
|
||||
thinking: { type: "disabled" },
|
||||
});
|
||||
return completion.choices[0]?.message?.content ?? `${isPDF ? "PDF" : "Word"} 生成完成`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 主流程:个股分析
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export async function run(rawInput: unknown): Promise<{
|
||||
success: boolean; format: OutputFormat; content: string;
|
||||
summary: { total: number; buy: number; watch: number; sell: number; errors: number };
|
||||
error?: string;
|
||||
}> {
|
||||
let parsed: ReturnType<typeof parseInput>;
|
||||
try { parsed = parseInput(rawInput); }
|
||||
catch (err: any) {
|
||||
return { success: false, format: "markdown", content: `❌ 输入解析失败:${err.message}`,
|
||||
summary: { total: 0, buy: 0, watch: 0, sell: 0, errors: 0 }, error: err.message };
|
||||
}
|
||||
|
||||
const { stocks, positions, outputFormat, mode, includeMarketReview, includeGlobalMacro, includeDividend } = parsed;
|
||||
if (!stocks.length) return { success: false, format: outputFormat, content: "❌ 未提供股票代码",
|
||||
summary: { total: 0, buy: 0, watch: 0, sell: 0, errors: 0 } };
|
||||
|
||||
log(`分析 ${stocks.length} 只:${stocks.join(", ")} | 格式:${outputFormat}`);
|
||||
|
||||
// 并行获取宏观数据
|
||||
let globalMacro: string | undefined;
|
||||
let marketOverview: string | undefined;
|
||||
await Promise.all([
|
||||
includeGlobalMacro ? fetchGlobalMacro().then((r) => { globalMacro = r; }) : Promise.resolve(),
|
||||
includeMarketReview ? fetchMarketOverview().then((r) => { marketOverview = r; }) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
// 抓取个股数据
|
||||
let stockDataList;
|
||||
try {
|
||||
stockDataList = await fetchMultipleStocks(stocks, mode);
|
||||
log(`数据完成:${stockDataList.filter((d) => !d.error).length}/${stocks.length}`);
|
||||
} catch (err: any) {
|
||||
return { success: false, format: outputFormat, content: `❌ 数据抓取失败:${err.message}`,
|
||||
summary: { total: stocks.length, buy: 0, watch: 0, sell: 0, errors: stocks.length }, error: err.message };
|
||||
}
|
||||
|
||||
// LLM 分析
|
||||
const analysisResults = await analyzeMultipleStocks(stockDataList, outputFormat, positions, includeDividend);
|
||||
|
||||
// 检查自选股信号变化(如有)
|
||||
const signals: Record<string, Verdict> = {};
|
||||
for (const r of analysisResults) signals[r.code] = r.verdict;
|
||||
await checkAlerts(signals).catch(() => {}); // 静默更新,不阻塞主流程
|
||||
|
||||
// 生成报告
|
||||
let content: string;
|
||||
try {
|
||||
const markdown = buildFullReport(analysisResults, globalMacro, marketOverview);
|
||||
if (outputFormat === "pdf") content = await exportToFormat(markdown, "pdf");
|
||||
else if (outputFormat === "word") content = await exportToFormat(markdown, "word");
|
||||
else content = markdown;
|
||||
} catch (err: any) {
|
||||
return { success: false, format: outputFormat, content: `❌ 报告生成失败:${err.message}`,
|
||||
summary: { total: stocks.length, buy: 0, watch: 0, sell: 0, errors: stocks.length }, error: err.message };
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: analysisResults.length,
|
||||
buy: analysisResults.filter((r) => ["买入", "强烈买入"].includes(r.verdict)).length,
|
||||
watch: analysisResults.filter((r) => r.verdict === "观望").length,
|
||||
sell: analysisResults.filter((r) => r.verdict === "卖出").length,
|
||||
errors: stockDataList.filter((d) => d.error).length,
|
||||
};
|
||||
|
||||
log(`完成 ✅ 买入:${summary.buy} 观望:${summary.watch} 卖出:${summary.sell}`);
|
||||
return { success: true, format: outputFormat, content, summary };
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 股息分析
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export async function runDividend(tickers: string | string[]): Promise<string> {
|
||||
const codes = Array.isArray(tickers) ? tickers : tickers.split(/[,,\s]+/).filter(Boolean);
|
||||
log(`股息分析:${codes.join(", ")}`);
|
||||
const results = await analyzeDividends(codes.map((c) => c.toUpperCase()));
|
||||
|
||||
let output = `# 💰 股息分析报告\n**生成时间:** ${new Date().toLocaleDateString("zh-CN")}\n\n---\n\n`;
|
||||
for (const r of results) output += formatDividendMarkdown(r) + "\n\n---\n\n";
|
||||
output += "_以上数据仅供参考,不构成投资建议。_";
|
||||
return output;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 传闻扫描
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export async function runRumorScan(): Promise<string> {
|
||||
log("开始传闻扫描...");
|
||||
const result = await scanRumors();
|
||||
return formatRumorMarkdown(result);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 自选股管理
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export async function runWatchlistAdd(
|
||||
ticker: string,
|
||||
opts: { targetPrice?: number; stopPrice?: number; alertOnSignal?: boolean; notes?: string } = {}
|
||||
): Promise<string> {
|
||||
const result = await addToWatchlist(ticker, opts);
|
||||
return result.message;
|
||||
}
|
||||
|
||||
export async function runWatchlistRemove(ticker: string): Promise<string> {
|
||||
const result = await removeFromWatchlist(ticker);
|
||||
return result.message;
|
||||
}
|
||||
|
||||
export async function runWatchlistList(): Promise<string> {
|
||||
const data = await listWatchlist();
|
||||
return formatWatchlistMarkdown(data);
|
||||
}
|
||||
|
||||
export async function runWatchlistCheck(): Promise<string> {
|
||||
const result = await checkAlerts();
|
||||
return formatAlertsMarkdown(result);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// K线图分析
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
export async function runChartAnalysis(
|
||||
stockCode: string,
|
||||
imageUrlOrBase64: string,
|
||||
isBase64 = false
|
||||
): Promise<{ success: boolean; content: string }> {
|
||||
try {
|
||||
const result = await analyzeChartImage(imageUrlOrBase64, stockCode, isBase64);
|
||||
return { success: true, content: result };
|
||||
} catch (err: any) {
|
||||
return { success: false, content: `K线图分析失败:${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// CLI 调试
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
async function cli() {
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
|
||||
if (!cmd || cmd === "--help") {
|
||||
console.log(`
|
||||
Stock Analysis Skill CLI
|
||||
|
||||
命令:
|
||||
analyze <代码,...> [markdown|pdf|word] [--market-review] [--dividend]
|
||||
dividend <代码,...>
|
||||
rumors
|
||||
watch add <代码> [--target 价格] [--stop 价格] [--signal]
|
||||
watch remove <代码>
|
||||
watch list
|
||||
watch check
|
||||
|
||||
示例:
|
||||
ts-node src/index.ts analyze 600519,00700.HK,AAPL
|
||||
ts-node src/index.ts analyze AAPL --dividend
|
||||
ts-node src/index.ts dividend JNJ PG KO
|
||||
ts-node src/index.ts rumors
|
||||
ts-node src/index.ts watch add AAPL --target 200 --stop 150
|
||||
ts-node src/index.ts watch list
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === "analyze") {
|
||||
const codes = (args[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const format = (["markdown", "pdf", "word"].find((f) => args.includes(f)) as OutputFormat) ?? "markdown";
|
||||
const includeMarketReview = args.includes("--market-review");
|
||||
const includeDividend = args.includes("--dividend");
|
||||
const result = await run({ stocks: codes, outputFormat: format, includeMarketReview, includeDividend });
|
||||
console.log(result.content);
|
||||
console.log("\n📊 汇总:", result.summary);
|
||||
|
||||
} else if (cmd === "dividend") {
|
||||
const codes = args.slice(1).filter((a) => !a.startsWith("--"));
|
||||
console.log(await runDividend(codes));
|
||||
|
||||
} else if (cmd === "rumors") {
|
||||
console.log(await runRumorScan());
|
||||
|
||||
} else if (cmd === "watch") {
|
||||
const sub = args[1];
|
||||
if (sub === "add") {
|
||||
const ticker = args[2];
|
||||
const targetIdx = args.indexOf("--target");
|
||||
const stopIdx = args.indexOf("--stop");
|
||||
console.log(await runWatchlistAdd(ticker, {
|
||||
targetPrice: targetIdx >= 0 ? Number(args[targetIdx + 1]) : undefined,
|
||||
stopPrice: stopIdx >= 0 ? Number(args[stopIdx + 1]) : undefined,
|
||||
alertOnSignal: args.includes("--signal"),
|
||||
}));
|
||||
} else if (sub === "remove") {
|
||||
console.log(await runWatchlistRemove(args[2]));
|
||||
} else if (sub === "list") {
|
||||
console.log(await runWatchlistList());
|
||||
} else if (sub === "check") {
|
||||
console.log(await runWatchlistCheck());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
cli().catch((err) => { console.error(err); process.exit(1); });
|
||||
}
|
||||
Reference in new issue
Block a user