1 line
32 KiB
JSON
1 line
32 KiB
JSON
{"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"}}}} |