Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/c0r1_grok-secintel-daily.json
T

1 line
32 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "c0r1/grok-secintel-daily", "files": {"grok.py": {"87ad63e1bf0a23643a0ea1468a038b4b18825dfb": {"t": 1785674555.2657886, "n": 12742, "h": "f2af7c5604dfb724", "c": "#!/usr/bin/env python3\n\"\"\"Grok 网络安全情报生成脚本。\n\n- 串行调用 fast 多次生成草稿,再由 fast 汇总为最终报告\n- 输出路径:outputs/YYYY/MM/YYYY-MM-DD.md(按 Asia/Shanghai)\n- DRY_RUN=1:不调用 API,仅验证模板填充与写文件\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport random\nimport re\nimport sys\nimport time\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom zoneinfo import ZoneInfo\n\ntry:\n from openai import OpenAI\nexcept ModuleNotFoundError:\n OpenAI = None # type: ignore[assignment]\n\n# --- 常量配置 ---\nFAST_MODEL = \"grok-4.20-fast\"\nTHINKING_MODEL = FAST_MODEL\nTEMPERATURES = [0.1, 0.6, 1.0]\nTHINKING_TEMPERATURE = 0.1\nAPI_TIMEOUT = 300.0 # 秒\nMAX_RETRIES = 2 # 最多重试 2 次(总共尝试 3 次)\nMAX_CONTENT_LENGTH = 15000 # 每份 fast 输出的最大字符数,避免 thinking 上下文过长\nRETRY_MIN_DELAY = 2.0\nRETRY_MAX_DELAY = 5.0\nPROMPT_TEMPLATE_FILE = \"prompt_template.txt\"\nOUTPUT_DIR = \"outputs\"\nLATEST_REPORT_FILE = \"LATEST.md\"\nTIMEZONE = \"Asia/Shanghai\"\n# --- 汇总 Prompt 模板 ---\nSUMMARY_PROMPT_TEMPLATE = \"\"\"你是一个专业的网络安全情报审查和整合专家。\n\n我提供了 {n} 份由 AI 生成的\"网络安全情报速报\"(来自同一个 prompt 的多次生成结果)。\n由于每次生成存在随机性,各份内容在情报条目、互动数据、链接等方面可能存在差异。\n\n请你按以下规则整合输出最终版:\n\n【整合规则】\n1. **按板块逐一处理**: 依次处理每个板块(最高赏金、云安全重点、Writeup、热议话题等)\n2. **情报去重与合并**:\n - 若多份内容提及同一情报(相同漏洞/相同推文),合并为一条\n - 保留信息最完整、描述最准确的版本\n - 互动数据(点赞/转发/浏览)取各份中的最大值\n3. **链接校验**: 若同一情报在不同版本中链接不同,优先保留格式完整的推文链接\n4. **Top 限制**: 每个板块最终保留 Top 3-5 条最有价值的情报(云安全板块可适当放宽)\n5. **优先级排序**: 在每个板块内,按互动数据(点赞优先)降序排列\n6. **热度峰值更新**: 统计整合后所有情报中的最高点赞数和最高浏览量,更新开头的\"本期热度峰值\"\n7. **情报眼提炼**: 基于整合后的全部情报,重新提炼一句 20 字以内的趋势概括\n\n【输出要求】\n- 严格保持原有的输出格式和板块结构\n- 所有中文描述保持专业、干练\n- 不要添加原内容中没有的情报\n- 不要输出你的分析过程,直接输出最终整合后的完整报告\n\n**整合要求补充(硬约束)**\n1. **板块完整性**:无论输入草稿是否为空,最终必须保留全部板块;为空就用统一句式“本期无高互动/突破性情报”。\n2. **时间窗口硬约束**:所有收录事件的发布时间必须在报告“统计范围”窗口内;如需引用背景,必须标注“历史参考”,且不得计入本期热度峰值。\n3. **热度峰值一致性**:热度峰值必须从最终保留条目中计算(取最大值),且峰值对应事件在正文中必须有详细条目。\n4. **情报眼锚定**:今日情报眼默认锚定在“热度峰值”对应事件;若有多个热点,优先级:Critical/大规模安全事件 > 密钥泄露/供应链事件 > 新工具/方法讨论。\n5. **去重优先级**:同一事件多版本时,优先保留互动最高且技术细节最完整的版本;链接保留互动最高的那条作为主来源。\n6. **你是审稿器而非作者**:不要重新创作或补充不存在的情报,只做合并、去重、排序、标注与纠错。\n\n---\n\n{contents}\"\"\"\n\n\ndef get_now() -> datetime:\n return datetime.now(ZoneInfo(TIMEZONE))\n\n\ndef is_dry_run() -> bool:\n return os.environ.get(\"DRY_RUN\", \"0\") == \"1\"\n\n\ndef log(message: str) -> None:\n print(message, flush=True)\n\n\ndef load_env_config() -> tuple[str, str]:\n api_key = os.environ.get(\"XAI_API_KEY\", \"\")\n base_url = os.environ.get(\"XAI_BASE_URL\", \"\")\n\n if not api_key:\n log(\"ERROR: 环境变量 XAI_API_KEY 未设置\")\n sys.exit(1)\n if not base_url:\n log(\"ERROR: 环境变量 XAI_BASE_URL 未设置\")\n sys.exit(1)\n\n return api_key, base_url\n\n\ndef sanitize_model_output(content: str) -> str:\n cleaned = re.sub(\n r\"<think\\b[^>]*>.*?(?:</think>|$)\",\n \"\",\n content,\n flags=re.IGNORECASE | re.DOTALL\n )\n cleaned = re.sub(\n r\"^\\s*\\[Agent\\s+\\d+\\]\\[AgentThink\\].*$\", \"\", cleaned, flags=re.MULTILINE\n )\n return cleaned.strip()\n\n\ndef load_prompt_template(filepath: str, now: datetime) -> str:\n \"\"\"加载并填充 prompt 模板(使用统一 now,避免跨天不一致)。\"\"\"\n if not os.path.exists(filepath):\n log(f\"ERROR: prompt 模板文件不存在: {filepath}\")\n sys.exit(1)\n\n with open(filepath, \"r\", encoding=\"utf-8\") as f:\n template = f.read()\n\n log(f\"加载 prompt 模板: {filepath} ({len(template)} 字符)\")\n\n # 填充占位符(使用传入的基准时间)\n prompt = template.replace(\"{{CURRENT_TIME}}\", now.strftime(\"%Y-%m-%d %H:%M:%S\"))\n prompt = prompt.replace(\"{{END_DATE}}\", now.strftime(\"%Y-%m-%d\"))\n prompt = prompt.replace(\n \"{{START_DATE}}\", (now - timedelta(days=3)).strftime(\"%Y-%m-%d\")\n )\n prompt = prompt.replace(\"{{PREVIOUS_YEAR}}\", str(now.year - 1))\n\n return prompt\n\n\ndef extract_response_content(response: object) -> str | None:\n if isinstance(response, str):\n parts = []\n saw_sse_data = False\n for line in response.splitlines():\n line = line.strip()\n if not line or line.startswith(\":\"):\n continue\n if not line.startswith(\"data:\"):\n continue\n saw_sse_data = True\n payload = line[5:].strip()\n if payload == \"[DONE]\":\n break\n try:\n data = json.loads(payload)\n except Exception:\n continue\n if not isinstance(data, dict):\n continue\n choices = data.get(\"choices\")\n if not isinstance(choices, list) or not choices:\n continue\n first = choices[0]\n if not isinstance(first, dict):\n continue\n delta = first.get(\"delta\")\n if isinstance(delta, dict):\n piece = delta.get(\"content\")\n if isinstance(piece, str):\n parts.append(piece)\n continue\n message = first.get(\"message\")\n if isinstance(message, dict):\n piece = message.get(\"content\")\n if isinstance(piece, str):\n parts.append(piece)\n\n if saw_sse_data:\n content = sanitize_model_output(\"\".join(parts))\n return content or None\n return None\n\n content = response.choices[0].message.content\n if isinstance(content, str):\n return sanitize_model_output(content)\n return None\n\n\ndef call_with_retry(\n client: OpenAI,\n model: str,\n prompt: str,\n temperature: float,\n label: str,\n) -> str | None:\n \"\"\"带重试的 API 调用;成功返回内容,失败返回 None。\"\"\"\n for attempt in range(MAX_RETRIES + 1):\n try:\n if attempt == 0:\n log(f\" {label} ...\")\n else:\n delay = random.uniform(RETRY_MIN_DELAY, RETRY_MAX_DELAY)\n log(f\" {label} retry {attempt}/{MAX_RETRIES} (sleep {delay:.1f}s)...\")\n time.sleep(delay)\n\n start_time = time.time()\n response = client.chat.completions.create(\n model=model,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n temperature=temperature,\n stream=False,\n )\n elapsed = time.time() - start_time\n\n content = extract_response_content(response)\n if content:\n content = content.strip()\n log(f\" {label} ok ({elapsed:.1f}s, {len(content)} chars)\")\n return content\n else:\n log(f\" {label} empty response\")\n\n except Exception as e:\n err_str = str(e)\n # 不可恢复的 429:账户无权限,跳过重试\n if \"No available accounts\" in err_str or \"model tier\" in err_str:\n log(f\" {label} FATAL: 账户无此模型权限,跳过重试 — {e}\")\n return None\n if attempt == MAX_RETRIES:\n log(f\" {label} ERROR (final): {e}\")\n else:\n log(f\" {label} ERROR: {e}\")\n\n return None\n\n\ndef sequential_call_grok_fast(\n client: OpenAI,\n prompt: str,\n temperatures: list[float],\n) -> list[str | None]:\n \"\"\"串行调用 fast 多次。\"\"\"\n log(f\"\\n调用 {FAST_MODEL} x {len(temperatures)}\")\n results = []\n\n for i, temp in enumerate(temperatures, 1):\n label = f\"[{i}/{len(temperatures)}] temp={temp}\"\n result = call_with_retry(client, FAST_MODEL, prompt, temp, label)\n results.append(result)\n\n return results\n\n\ndef call_thinking_model(\n client: OpenAI,\n contents: list[str],\n) -> str | None:\n \"\"\"调用汇总模型审查。\"\"\"\n log(f\"\\n调用 {THINKING_MODEL} 汇总\")\n\n contents_text = \"\"\n for i, content in enumerate(contents, 1):\n if len(content) > MAX_CONTENT_LENGTH:\n content = content[:MAX_CONTENT_LENGTH] + \"\\n\\n[内容已截断...]\"\n log(f\" WARN: 第 {i} 份内容超过 {MAX_CONTENT_LENGTH} 字符,已截断\")\n contents_text += f\"【第 {i} 份内容】\\n{content}\\n\\n---\\n\\n\"\n\n summary_prompt = SUMMARY_PROMPT_TEMPLATE.format(\n n=len(contents),\n contents=contents_text.strip(),\n )\n\n result = call_with_retry(\n client,\n THINKING_MODEL,\n summary_prompt,\n THINKING_TEMPERATURE,\n \"[汇总]\",\n )\n\n return result\n\n\ndef save_to_file(content: str, now: datetime) -> str:\n \"\"\"保存内容到 outputs/YYYY/MM/YYYY-MM-DD.md(按传入 now)。\"\"\"\n year = now.strftime(\"%Y\")\n month = now.strftime(\"%m\")\n date = now.strftime(\"%Y-%m-%d\")\n\n output_dir = Path(OUTPUT_DIR) / year / month\n output_dir.mkdir(parents=True, exist_ok=True)\n\n filepath = output_dir / f\"{date}.md\"\n with open(filepath, \"w\", encoding=\"utf-8\") as f:\n f.write(content.rstrip() + \"\\n\")\n\n log(f\"\\n保存文件: {filepath}\")\n return str(filepath)\n\n\ndef save_latest(content: str) -> str:\n \"\"\"保存最新报告到仓库根目录,便于直接查看。\"\"\"\n filepath = Path(LATEST_REPORT_FILE)\n with open(filepath, \"w\", encoding=\"utf-8\") as f:\n f.write(content.rstrip() + \"\\n\")\n log(f\"保存文件: {filepath}\")\n return str(filepath)\n\n\ndef inject_generated_time(content: str, now: datetime) -> str:\n \"\"\"在报告头部注入生成时间(使用 now),避免依赖模型是否复述 {{CURRENT_TIME}}。\"\"\"\n generated_line = f\"> 生成时间: {now.strftime('%Y-%m-%d %H:%M:%S')} UTC+08:00\"\n\n lines = content.splitlines()\n head = \"\\n\".join(lines[:30])\n if re.search(r\"(^|\\n)\\s*(>|-|\\*)?\\s*生成时间\\s*[::]\", head):\n return content\n\n if not lines:\n return generated_line + \"\\n\"\n\n insert_at = 0\n for idx, line in enumerate(lines):\n if line.strip():\n insert_at = idx + 1\n break\n\n out_lines = lines[:insert_at] + [\"\", generated_line, \"\"] + lines[insert_at:]\n return \"\\n\".join(out_lines).lstrip(\"\\n\")\n\n\ndef main() -> None:\n start_time = time.time()\n\n # 在整个流程中统一使用同一个时间点,避免跨天边界不一致\n now = get_now()\n report_date = now.strftime(\"%Y-%m-%d\")\n dry_run = is_dry_run()\n\n if dry_run:\n log(\"DRY_RUN=1\")\n log(f\"开始 [{now.strftime('%Y-%m-%d %H:%M:%S')} UTC+08:00]\")\n\n # 1. 加载配置\n log(\"\\n检查环境变量...\")\n api_key, base_url = load_env_config()\n log(\" XAI_API_KEY: [REDACTED]\")\n log(\" XAI_BASE_URL: [REDACTED]\")\n\n if not dry_run:\n if OpenAI is None:\n log(\"ERROR: 依赖未安装:openai 包缺失(正常运行需要,DRY_RUN 可不安装)\")\n sys.exit(1)\n client = OpenAI(\n api_key=api_key,\n base_url=base_url,\n timeout=API_TIMEOUT,\n default_headers={\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36\"\n },\n )\n\n # 2. 加载 prompt 模板(传入统一的时间点)\n log(\"\\n检查 prompt 模板...\")\n prompt = load_prompt_template(PROMPT_TEMPLATE_FILE, now)\n log(f\" prompt 长度: {len(prompt)} 字符\")\n log(\n f\" 窗口: {(now - timedelta(days=3)).strftime('%Y-%m-%d')}~{now.strftime('%Y-%m-%d')}\"\n )\n\n # 3. 干跑模式:跳过 API 调用,生成模拟内容\n if dry_run:\n log(\"\\nDRY_RUN:跳过 API 调用\")\n final_content = (\n \"# DRY_RUN\\n\\n\"\n f\"- time: {now.strftime('%Y-%m-%d %H:%M:%S')} UTC+08:00\\n\"\n f\"- report_date: {report_date}\\n\"\n f\"- window: {(now - timedelta(days=3)).strftime('%Y-%m-%d')}~{now.strftime('%Y-%m-%d')}\\n\"\n f\"- prompt_len: {len(prompt)}\\n\"\n f\"- output: {OUTPUT_DIR}/{now.strftime('%Y/%m/%Y-%m-%d')}.md\\n\"\n )\n\n else:\n # 3. 串行调用 grok-4-fast × 3\n results = sequential_call_grok_fast(client, prompt, TEMPERATURES)\n\n # 4. 检查结果(降级模式)\n valid_results = [r for r in results if r is not None]\n success_count = len(valid_results)\n total_count = len(TEMPERATURES)\n\n log(f\"\\n{FAST_MODEL} 结果: {success_count}/{total_count} 成功\")\n\n if success_count == 0:\n log(f\"ERROR: {FAST_MODEL} 全部失败,任务中止\")\n sys.exit(1)\n\n # 5. 调用 thinking 模型汇总\n final_content = call_thinking_model(client, valid_results)\n\n if final_content is None:\n log(f\"ERROR: {THINKING_MODEL} 调用失败,任务中止\")\n sys.exit(1)\n\n # 6. 保存到本地文件(传入统一的时间点)\n log(\"\\n写入输出文件...\")\n final_content = sanitize_model_output(final_content)\n final_content = inject_generated_time(final_content, now)\n output_path = save_to_file(final_content, now)\n log(f\" ok: {output_path}\")\n latest_path = save_latest(final_content)\n log(f\" ok: {latest_path}\")\n\n # 7. 输出日期供 workflow 使用(使用同一个 now,确保与文件名一致)\n log(f\"\\n报告日期: {report_date}\")\n log(f\"输出路径: {output_path}\")\n\n # 输出 GitHub Actions 可读取的格式\n github_output = os.environ.get(\"GITHUB_OUTPUT\")\n if github_output:\n with open(github_output, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"report_date={report_date}\\n\")\n f.write(f\"output_path={output_path}\\n\")\n log(\" 已写入 GITHUB_OUTPUT\")\n\n # 8. 完成\n elapsed = time.time() - start_time\n if dry_run:\n log(f\"\\n完成 (DRY_RUN, {elapsed:.1f}s)\")\n else:\n log(f\"\\n完成 ({elapsed:.1f}s)\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "0b942dc7d96fd09984daa65b5ad52a7559f7150f": {"t": 1785846893.2464585, "n": 12742, "h": "f2af7c5604dfb724", "c": "#!/usr/bin/env python3\n\"\"\"Grok 网络安全情报生成脚本。\n\n- 串行调用 fast 多次生成草稿,再由 fast 汇总为最终报告\n- 输出路径:outputs/YYYY/MM/YYYY-MM-DD.md(按 Asia/Shanghai)\n- DRY_RUN=1:不调用 API,仅验证模板填充与写文件\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport random\nimport re\nimport sys\nimport time\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom zoneinfo import ZoneInfo\n\ntry:\n from openai import OpenAI\nexcept ModuleNotFoundError:\n OpenAI = None # type: ignore[assignment]\n\n# --- 常量配置 ---\nFAST_MODEL = \"grok-4.20-fast\"\nTHINKING_MODEL = FAST_MODEL\nTEMPERATURES = [0.1, 0.6, 1.0]\nTHINKING_TEMPERATURE = 0.1\nAPI_TIMEOUT = 300.0 # 秒\nMAX_RETRIES = 2 # 最多重试 2 次(总共尝试 3 次)\nMAX_CONTENT_LENGTH = 15000 # 每份 fast 输出的最大字符数,避免 thinking 上下文过长\nRETRY_MIN_DELAY = 2.0\nRETRY_MAX_DELAY = 5.0\nPROMPT_TEMPLATE_FILE = \"prompt_template.txt\"\nOUTPUT_DIR = \"outputs\"\nLATEST_REPORT_FILE = \"LATEST.md\"\nTIMEZONE = \"Asia/Shanghai\"\n# --- 汇总 Prompt 模板 ---\nSUMMARY_PROMPT_TEMPLATE = \"\"\"你是一个专业的网络安全情报审查和整合专家。\n\n我提供了 {n} 份由 AI 生成的\"网络安全情报速报\"(来自同一个 prompt 的多次生成结果)。\n由于每次生成存在随机性,各份内容在情报条目、互动数据、链接等方面可能存在差异。\n\n请你按以下规则整合输出最终版:\n\n【整合规则】\n1. **按板块逐一处理**: 依次处理每个板块(最高赏金、云安全重点、Writeup、热议话题等)\n2. **情报去重与合并**:\n - 若多份内容提及同一情报(相同漏洞/相同推文),合并为一条\n - 保留信息最完整、描述最准确的版本\n - 互动数据(点赞/转发/浏览)取各份中的最大值\n3. **链接校验**: 若同一情报在不同版本中链接不同,优先保留格式完整的推文链接\n4. **Top 限制**: 每个板块最终保留 Top 3-5 条最有价值的情报(云安全板块可适当放宽)\n5. **优先级排序**: 在每个板块内,按互动数据(点赞优先)降序排列\n6. **热度峰值更新**: 统计整合后所有情报中的最高点赞数和最高浏览量,更新开头的\"本期热度峰值\"\n7. **情报眼提炼**: 基于整合后的全部情报,重新提炼一句 20 字以内的趋势概括\n\n【输出要求】\n- 严格保持原有的输出格式和板块结构\n- 所有中文描述保持专业、干练\n- 不要添加原内容中没有的情报\n- 不要输出你的分析过程,直接输出最终整合后的完整报告\n\n**整合要求补充(硬约束)**\n1. **板块完整性**:无论输入草稿是否为空,最终必须保留全部板块;为空就用统一句式“本期无高互动/突破性情报”。\n2. **时间窗口硬约束**:所有收录事件的发布时间必须在报告“统计范围”窗口内;如需引用背景,必须标注“历史参考”,且不得计入本期热度峰值。\n3. **热度峰值一致性**:热度峰值必须从最终保留条目中计算(取最大值),且峰值对应事件在正文中必须有详细条目。\n4. **情报眼锚定**:今日情报眼默认锚定在“热度峰值”对应事件;若有多个热点,优先级:Critical/大规模安全事件 > 密钥泄露/供应链事件 > 新工具/方法讨论。\n5. **去重优先级**:同一事件多版本时,优先保留互动最高且技术细节最完整的版本;链接保留互动最高的那条作为主来源。\n6. **你是审稿器而非作者**:不要重新创作或补充不存在的情报,只做合并、去重、排序、标注与纠错。\n\n---\n\n{contents}\"\"\"\n\n\ndef get_now() -> datetime:\n return datetime.now(ZoneInfo(TIMEZONE))\n\n\ndef is_dry_run() -> bool:\n return os.environ.get(\"DRY_RUN\", \"0\") == \"1\"\n\n\ndef log(message: str) -> None:\n print(message, flush=True)\n\n\ndef load_env_config() -> tuple[str, str]:\n api_key = os.environ.get(\"XAI_API_KEY\", \"\")\n base_url = os.environ.get(\"XAI_BASE_URL\", \"\")\n\n if not api_key:\n log(\"ERROR: 环境变量 XAI_API_KEY 未设置\")\n sys.exit(1)\n if not base_url:\n log(\"ERROR: 环境变量 XAI_BASE_URL 未设置\")\n sys.exit(1)\n\n return api_key, base_url\n\n\ndef sanitize_model_output(content: str) -> str:\n cleaned = re.sub(\n r\"<think\\b[^>]*>.*?(?:</think>|$)\",\n \"\",\n content,\n flags=re.IGNORECASE | re.DOTALL\n )\n cleaned = re.sub(\n r\"^\\s*\\[Agent\\s+\\d+\\]\\[AgentThink\\].*$\", \"\", cleaned, flags=re.MULTILINE\n )\n return cleaned.strip()\n\n\ndef load_prompt_template(filepath: str, now: datetime) -> str:\n \"\"\"加载并填充 prompt 模板(使用统一 now,避免跨天不一致)。\"\"\"\n if not os.path.exists(filepath):\n log(f\"ERROR: prompt 模板文件不存在: {filepath}\")\n sys.exit(1)\n\n with open(filepath, \"r\", encoding=\"utf-8\") as f:\n template = f.read()\n\n log(f\"加载 prompt 模板: {filepath} ({len(template)} 字符)\")\n\n # 填充占位符(使用传入的基准时间)\n prompt = template.replace(\"{{CURRENT_TIME}}\", now.strftime(\"%Y-%m-%d %H:%M:%S\"))\n prompt = prompt.replace(\"{{END_DATE}}\", now.strftime(\"%Y-%m-%d\"))\n prompt = prompt.replace(\n \"{{START_DATE}}\", (now - timedelta(days=3)).strftime(\"%Y-%m-%d\")\n )\n prompt = prompt.replace(\"{{PREVIOUS_YEAR}}\", str(now.year - 1))\n\n return prompt\n\n\ndef extract_response_content(response: object) -> str | None:\n if isinstance(response, str):\n parts = []\n saw_sse_data = False\n for line in response.splitlines():\n line = line.strip()\n if not line or line.startswith(\":\"):\n continue\n if not line.startswith(\"data:\"):\n continue\n saw_sse_data = True\n payload = line[5:].strip()\n if payload == \"[DONE]\":\n break\n try:\n data = json.loads(payload)\n except Exception:\n continue\n if not isinstance(data, dict):\n continue\n choices = data.get(\"choices\")\n if not isinstance(choices, list) or not choices:\n continue\n first = choices[0]\n if not isinstance(first, dict):\n continue\n delta = first.get(\"delta\")\n if isinstance(delta, dict):\n piece = delta.get(\"content\")\n if isinstance(piece, str):\n parts.append(piece)\n continue\n message = first.get(\"message\")\n if isinstance(message, dict):\n piece = message.get(\"content\")\n if isinstance(piece, str):\n parts.append(piece)\n\n if saw_sse_data:\n content = sanitize_model_output(\"\".join(parts))\n return content or None\n return None\n\n content = response.choices[0].message.content\n if isinstance(content, str):\n return sanitize_model_output(content)\n return None\n\n\ndef call_with_retry(\n client: OpenAI,\n model: str,\n prompt: str,\n temperature: float,\n label: str,\n) -> str | None:\n \"\"\"带重试的 API 调用;成功返回内容,失败返回 None。\"\"\"\n for attempt in range(MAX_RETRIES + 1):\n try:\n if attempt == 0:\n log(f\" {label} ...\")\n else:\n delay = random.uniform(RETRY_MIN_DELAY, RETRY_MAX_DELAY)\n log(f\" {label} retry {attempt}/{MAX_RETRIES} (sleep {delay:.1f}s)...\")\n time.sleep(delay)\n\n start_time = time.time()\n response = client.chat.completions.create(\n model=model,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n temperature=temperature,\n stream=False,\n )\n elapsed = time.time() - start_time\n\n content = extract_response_content(response)\n if content:\n content = content.strip()\n log(f\" {label} ok ({elapsed:.1f}s, {len(content)} chars)\")\n return content\n else:\n log(f\" {label} empty response\")\n\n except Exception as e:\n err_str = str(e)\n # 不可恢复的 429:账户无权限,跳过重试\n if \"No available accounts\" in err_str or \"model tier\" in err_str:\n log(f\" {label} FATAL: 账户无此模型权限,跳过重试 — {e}\")\n return None\n if attempt == MAX_RETRIES:\n log(f\" {label} ERROR (final): {e}\")\n else:\n log(f\" {label} ERROR: {e}\")\n\n return None\n\n\ndef sequential_call_grok_fast(\n client: OpenAI,\n prompt: str,\n temperatures: list[float],\n) -> list[str | None]:\n \"\"\"串行调用 fast 多次。\"\"\"\n log(f\"\\n调用 {FAST_MODEL} x {len(temperatures)}\")\n results = []\n\n for i, temp in enumerate(temperatures, 1):\n label = f\"[{i}/{len(temperatures)}] temp={temp}\"\n result = call_with_retry(client, FAST_MODEL, prompt, temp, label)\n results.append(result)\n\n return results\n\n\ndef call_thinking_model(\n client: OpenAI,\n contents: list[str],\n) -> str | None:\n \"\"\"调用汇总模型审查。\"\"\"\n log(f\"\\n调用 {THINKING_MODEL} 汇总\")\n\n contents_text = \"\"\n for i, content in enumerate(contents, 1):\n if len(content) > MAX_CONTENT_LENGTH:\n content = content[:MAX_CONTENT_LENGTH] + \"\\n\\n[内容已截断...]\"\n log(f\" WARN: 第 {i} 份内容超过 {MAX_CONTENT_LENGTH} 字符,已截断\")\n contents_text += f\"【第 {i} 份内容】\\n{content}\\n\\n---\\n\\n\"\n\n summary_prompt = SUMMARY_PROMPT_TEMPLATE.format(\n n=len(contents),\n contents=contents_text.strip(),\n )\n\n result = call_with_retry(\n client,\n THINKING_MODEL,\n summary_prompt,\n THINKING_TEMPERATURE,\n \"[汇总]\",\n )\n\n return result\n\n\ndef save_to_file(content: str, now: datetime) -> str:\n \"\"\"保存内容到 outputs/YYYY/MM/YYYY-MM-DD.md(按传入 now)。\"\"\"\n year = now.strftime(\"%Y\")\n month = now.strftime(\"%m\")\n date = now.strftime(\"%Y-%m-%d\")\n\n output_dir = Path(OUTPUT_DIR) / year / month\n output_dir.mkdir(parents=True, exist_ok=True)\n\n filepath = output_dir / f\"{date}.md\"\n with open(filepath, \"w\", encoding=\"utf-8\") as f:\n f.write(content.rstrip() + \"\\n\")\n\n log(f\"\\n保存文件: {filepath}\")\n return str(filepath)\n\n\ndef save_latest(content: str) -> str:\n \"\"\"保存最新报告到仓库根目录,便于直接查看。\"\"\"\n filepath = Path(LATEST_REPORT_FILE)\n with open(filepath, \"w\", encoding=\"utf-8\") as f:\n f.write(content.rstrip() + \"\\n\")\n log(f\"保存文件: {filepath}\")\n return str(filepath)\n\n\ndef inject_generated_time(content: str, now: datetime) -> str:\n \"\"\"在报告头部注入生成时间(使用 now),避免依赖模型是否复述 {{CURRENT_TIME}}。\"\"\"\n generated_line = f\"> 生成时间: {now.strftime('%Y-%m-%d %H:%M:%S')} UTC+08:00\"\n\n lines = content.splitlines()\n head = \"\\n\".join(lines[:30])\n if re.search(r\"(^|\\n)\\s*(>|-|\\*)?\\s*生成时间\\s*[::]\", head):\n return content\n\n if not lines:\n return generated_line + \"\\n\"\n\n insert_at = 0\n for idx, line in enumerate(lines):\n if line.strip():\n insert_at = idx + 1\n break\n\n out_lines = lines[:insert_at] + [\"\", generated_line, \"\"] + lines[insert_at:]\n return \"\\n\".join(out_lines).lstrip(\"\\n\")\n\n\ndef main() -> None:\n start_time = time.time()\n\n # 在整个流程中统一使用同一个时间点,避免跨天边界不一致\n now = get_now()\n report_date = now.strftime(\"%Y-%m-%d\")\n dry_run = is_dry_run()\n\n if dry_run:\n log(\"DRY_RUN=1\")\n log(f\"开始 [{now.strftime('%Y-%m-%d %H:%M:%S')} UTC+08:00]\")\n\n # 1. 加载配置\n log(\"\\n检查环境变量...\")\n api_key, base_url = load_env_config()\n log(\" XAI_API_KEY: [REDACTED]\")\n log(\" XAI_BASE_URL: [REDACTED]\")\n\n if not dry_run:\n if OpenAI is None:\n log(\"ERROR: 依赖未安装:openai 包缺失(正常运行需要,DRY_RUN 可不安装)\")\n sys.exit(1)\n client = OpenAI(\n api_key=api_key,\n base_url=base_url,\n timeout=API_TIMEOUT,\n default_headers={\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36\"\n },\n )\n\n # 2. 加载 prompt 模板(传入统一的时间点)\n log(\"\\n检查 prompt 模板...\")\n prompt = load_prompt_template(PROMPT_TEMPLATE_FILE, now)\n log(f\" prompt 长度: {len(prompt)} 字符\")\n log(\n f\" 窗口: {(now - timedelta(days=3)).strftime('%Y-%m-%d')}~{now.strftime('%Y-%m-%d')}\"\n )\n\n # 3. 干跑模式:跳过 API 调用,生成模拟内容\n if dry_run:\n log(\"\\nDRY_RUN:跳过 API 调用\")\n final_content = (\n \"# DRY_RUN\\n\\n\"\n f\"- time: {now.strftime('%Y-%m-%d %H:%M:%S')} UTC+08:00\\n\"\n f\"- report_date: {report_date}\\n\"\n f\"- window: {(now - timedelta(days=3)).strftime('%Y-%m-%d')}~{now.strftime('%Y-%m-%d')}\\n\"\n f\"- prompt_len: {len(prompt)}\\n\"\n f\"- output: {OUTPUT_DIR}/{now.strftime('%Y/%m/%Y-%m-%d')}.md\\n\"\n )\n\n else:\n # 3. 串行调用 grok-4-fast × 3\n results = sequential_call_grok_fast(client, prompt, TEMPERATURES)\n\n # 4. 检查结果(降级模式)\n valid_results = [r for r in results if r is not None]\n success_count = len(valid_results)\n total_count = len(TEMPERATURES)\n\n log(f\"\\n{FAST_MODEL} 结果: {success_count}/{total_count} 成功\")\n\n if success_count == 0:\n log(f\"ERROR: {FAST_MODEL} 全部失败,任务中止\")\n sys.exit(1)\n\n # 5. 调用 thinking 模型汇总\n final_content = call_thinking_model(client, valid_results)\n\n if final_content is None:\n log(f\"ERROR: {THINKING_MODEL} 调用失败,任务中止\")\n sys.exit(1)\n\n # 6. 保存到本地文件(传入统一的时间点)\n log(\"\\n写入输出文件...\")\n final_content = sanitize_model_output(final_content)\n final_content = inject_generated_time(final_content, now)\n output_path = save_to_file(final_content, now)\n log(f\" ok: {output_path}\")\n latest_path = save_latest(final_content)\n log(f\" ok: {latest_path}\")\n\n # 7. 输出日期供 workflow 使用(使用同一个 now,确保与文件名一致)\n log(f\"\\n报告日期: {report_date}\")\n log(f\"输出路径: {output_path}\")\n\n # 输出 GitHub Actions 可读取的格式\n github_output = os.environ.get(\"GITHUB_OUTPUT\")\n if github_output:\n with open(github_output, \"a\", encoding=\"utf-8\") as f:\n f.write(f\"report_date={report_date}\\n\")\n f.write(f\"output_path={output_path}\\n\")\n log(\" 已写入 GITHUB_OUTPUT\")\n\n # 8. 完成\n elapsed = time.time() - start_time\n if dry_run:\n log(f\"\\n完成 (DRY_RUN, {elapsed:.1f}s)\")\n else:\n log(f\"\\n完成 ({elapsed:.1f}s)\")\n\n\nif __name__ == \"__main__\":\n main()\n"}}}}