# continuation.md

> `checkpoint` @ 2026-08-05 08:30

---

## 当前状态

| 项目 | 值 |
| --- | --- |
| 新闻源 | 14 个（13 Web + 1 API: xwlb 新闻联播） |
| Qdrant | 本地文件模式 `data/qdrant_storage/` |
| 日报 | **M10 完成并已部署 pi5: 结构化入库 MySQL；日报按当天日期生成（新闻 30h 回溯 / xwlb 取前一日 / 公告调研近 15 日）** |
| DB 连接 | pi 上 systemd 服务 `a-share-db-tunnel` 常驻（0.0.0.0:13306 → doorcome.cn:3306）；**pi5 直连 192.168.1.10:13306** |
| 调度器 | APScheduler，systemd `a-share-research.service`（pi5）；每天 07:00 首次任务生成日报（12/18/22 点不生成） |
| LLM | `deepseek-v4-flash`（绝不允许擅自修改；模型必须显式配置，无内置兜底） |
| 服务器 | `pi@192.168.1.160`（生产）/ `pi@192.168.1.10`（DB 隧道宿主） |
| 抓取方式 | js_render=false → httpx 直连；js_render=true → Playwright |

---

## 本次完成 (2026-08-05) — 日报可靠性修复与取数逻辑优化

**目标：** 解决日报 AI 摘要偶发失败；修正日报日期与 xwlb/新闻取数语义。

**1. AI 摘要可靠性（llm/client.py + scheduler/reporter.py）：**
- 去掉内置默认模型兜底（`deepseek-chat`/`qwen-plus`），模型必须显式配置（`DEEPSEEK_MODEL`/`QWEN_MODEL` → `LLM_MODEL`），缺失即报错，避免静默用错模型
- `_llm_call` 增加指数退避重试：`_LLM_RETRY_TIMES`（默认 3 次）、`_LLM_RETRY_BACKOFF_SEC`（默认 2.0s，可 .env 覆盖），全部失败才抛异常
- 确认 AI 摘要模型：`deepseek` + `deepseek-v4-flash`（生产实测）

**2. 日报取数逻辑（scheduler/pipeline.py + reporter.py）：**
- pipeline report 步骤：`report_date = date.today()`（原为昨天+回溯 3 天）
- `_collect_news_events`：读当天+前一天事件目录，按 `publish_time` 过滤最近 30 小时（`_NEWS_LOOKBACK_HOURS=30`）；时区统一（naive 假定本地时区）；无时间戳事件保留
- `_collect_xwlb`：固定查 `day_str` 前一日（《新闻联播》19:00 播出，早间日报只能取昨晚已播出的一期）；`source_date` 同步为实际来源日
- **公告/调研/互动保持原设置：近 15 日（`STOCK_REPORT_DAYS=15`），irm 互动仍跳过**——未受 30h 改动影响

**验证（pi5）：**
- 单测 9 个（30h 回溯/带时区时间戳/重试/模型缺失报错/xwlb 前一日）全部通过；全量 212 passed + 3 crawler 预存在失败
- 生产端到端 report_id=191（2026-08-05）：news 20 + cninfo 20 + xwlb 34（08-04 联播），AI 摘要 2076 字
- 生产服务已重启生效

**本次代码尚未 git 提交（见待办）。**

---

## 本次完成 (2026-08-03 22:00) — pi5 部署与生产测试

**操作：** M10 代码全量同步 pi5 + 生产环境验收（所有测试在 pi5 执行，Mac 不再作为测试环境）。

**文件同步：** rsync 本地 → `pi5:/home/pi/news/`（排除 .venv/data/logs/.git/.env/configs/*.yaml）；清除 pi5 根目录 6 月 17 日旧版散文件（已 tar 备份 /tmp/news_backup_20260803.tar.gz）；pi5 `uv sync` 装 pymysql。

**DB 隧道修复：** pi 原 autossh 参数 `-L 13306:0.0.0.0:3306` 未生效（0.0.0.0 被当远端目标），改为 `-L 0.0.0.0:13306:127.0.0.1:3306` 并持久化为 systemd 服务 `a-share-db-tunnel`（enabled + active）。

**测试结果（pi5）：**
- 全量 pytest：**208 passed, 3 failed**（3 个失败均为 crawler retry mock 预先存在问题，与 M10 无关）
- `report-import` 幂等：已存在文件正确 skipped
- 生产端到端：`a-share report --date 20260802/20260803` → report_id=181/182 入库成功（AI 摘要正常，57/58 事件）
- DB 总量：finance 52 + intl 128 = 180 行（历史 177 + 端到端测试 2 + 本机 1）
- 生产服务 `a-share-research` 已重启 active，22:00 定时任务起用新代码

**已知问题：**
- `tests/test_crawler.py` 3 个 retry 测试失败（预先存在，crawl4ai mock 行为）
- Mac 本机 anaconda/uv python 出站到 192.168.1.10 被拦截（EHOSTUNREACH；nc/bash/系统 python 正常）——仅影响本机，pi5 不受影响；Mac 本地用 `127.0.0.1` + ssh 隧道绕过
- 生产 pi5 的 `.env` 里 `NEWS_DB_HOST=192.168.1.10`（直连）；Mac 本地 `.env` 为 `127.0.0.1`（隧道）——**两处 .env 不同，勿互相覆盖**

---

## 本次完成 (2026-08-03) — M10 日报结构化入库

**目标：** 日报前后端分离的数据层——日报内容结构化存入 MySQL（`news_` 前缀表），历史 178 份日报 HTML 解析入库；本项目不做 API/前端（用户另行实现）。

**表结构（myquant 库，见 docs/db_schema.md）：**
- `news_report`：主表，唯一键 (report_date, report_type, file_name)；`file_name=''` 表示新生成日报（每天每类型一行，重复生成覆盖）
- `news_event`：事件明细，section ∈ xwlb/news/cninfo/intl

**新增/修改文件：**
- `report_db/`（models/schema/db）：连接、建表、幂等 save_report
- `report_import/`（parser/importer）：历史 HTML 解析（表头驱动列映射）+ 批量导入
- `scheduler/reporter.py`：`generate_report()` 完全切换为结构化入库；`_render_html/_upload` 标记废弃保留
- `a_share_cli/main.py`：新增 `report-import` 子命令；`report` 命令改为入库提示
- `pyproject.toml`：`uv add pymysql`（唯一新增依赖）
- `.env`：新增 `NEWS_DB_*`（连接 127.0.0.1:13306，需 ssh 隧道）、`REPORT_HISTORY_DIR`
- `docs/report_db_design.md`（实现逻辑）、`docs/db_schema.md`（表结构，供 API/前端）

**验证结果：**
- 历史 178 份全部入库：scanned=178 imported=7(首轮)+170 skipped=171 failed=0；DB 177 行（finance 49 + intl 128，1 个跨目录同名文件被幂等合并）+ 事件 4222 条
- 端到端：`a-share report --date 20260616` → report_id=180 入库成功，无 HTML 产出
- 单测 24 个通过（parser 19 + builder 4 + models/import 补充）

**命令：**
```bash
ssh -L 13306:127.0.0.1:13306 pi        # Mac 开发连库隧道
uv run a-share report --date YYYYMMDD # 生成日报入库
uv run a-share report-import          # 历史导入(幂等)
```

**注意：** 本机 .env 无 LLM API key，AI 摘要会降级 WARNING（不影响入库）；生产 pi5 需配置 NEWS_DB_PASSWORD 且解决 13306 隧道可达性（见待确认项）。

---

## 历史 (2026-07-17) — 禁用个股日报

**操作：** `STOCK_REPORT_TIME=` 设为空，`run_scheduler.py` 加空值守卫。

**文件：**
- `scripts/run_scheduler.py` L127-141：`STOCK_REPORT_TIME` 为空时跳过注册并输出 `已禁用个股日报`
- Pi `.env`：`STOCK_REPORT_TIME=08:00` → `STOCK_REPORT_TIME=`

**恢复方法：** 改回 `STOCK_REPORT_TIME=08:00`，重启 `a-share-research`。

---

## 本次完成 (2026-07-13) — eeo 反爬修复 + 静态直连 + 超时保护

### 问题

eeo（经济观察网）在 Pi 上用 Playwright 连续抓取后触发反爬，服务器故意不响应导致 `page.goto()` 超时（30s → 60s 均超时）。而在本机 Mac 上一切正常——反爬是 **IP 级别**的，Pi 的 IP 已被限流。

### 根因

eeo 是纯静态页面（`js_render: false`），但 Crawl4AI 始终走 Playwright。eeo 检测到 headless Chrome 的连续请求模式后，对后续请求挂起 TCP 连接直至超时。

### 修复

**`crawler/engine.py` — 三个改动：**

1. **新增 `_fetch_static()`**：`js_render=False` 且无 `wait_for` 的源用 `httpx` 直连，绕过 Playwright 反爬。HTML → Markdown 用 `markdownify` + BeautifulSoup 清洗。

2. **新增 `_TimeoutGuard`**：同源连续超时 2 次后自动跳过剩余请求，防止整个 pipeline 被单一故障源拖死。

3. **`_crawl_once` 分流逻辑**：
   ```
   js_render=False 且无 wait_for → _fetch_static() (httpx)
   其他 → Playwright
   ```

**`configs/sources.yaml` — eeo 源两处调整：**
- 删除 `wait_for: "css:body"`（静态页面无需等待 CSS 选择器）
- `page_timeout_ms: 30000 → 60000`（恢复 Crawl4AI 默认值）

### 测试结果

| 环境 | Playwright (修复前) | httpx (修复后) |
|------|---------------------|-----------------|
| Mac 本地 | 全部通过 | 36 篇 / 7 秒 |
| Pi 服务器 | **前 3 个请求全部 15s 超时** | 36 篇 / 9 秒，0 失败 |

### 验证方法

Pi 上本地复现了反爬（Playwright 前 3 个 URL 全部超时），同批 URL 用 httpx 全部瞬间返回——证明方案有效。

---

## 历史 (2026-06-24)

### 1. 环境变量全面审计与修复

**触发：** 服务器 `dedup` 步骤超时 120s。

**修复 6 个文件：**

| # | 文件 | 修改 |
|---|------|------|
| 1 | `scheduler/pipeline.py` | 超时解析重写：`TIMEOUT_{NAME}` > `PIPELINE_STEP_TIMEOUT` > 硬编码 > 1800s |
| 2 | `a_share_cli/main.py` | 新增 `_resolve_timeout()`，CLI 命令超时改为读环境变量 |
| 3 | `crawler/cninfo.py` | `CNINFO_PDF_BASE` 从硬编码改为 `os.environ.get()` |
| 4 | `scheduler/reporter.py` | `STOCK_REPORT_DAYS` 默认值 7→15；`max_tokens` 600→1500 |
| 5 | `.env` | 清除 10 个死配置；`EMBEDDING_MODEL`→`LOCAL_EMBEDDING_MODEL`；补全遗漏参数 |
| 6 | `.env.example` | 重写为仅包含实际生效配置 |

**审计结果：**

| 类别 | 数量 | 处理 |
|------|------|------|
| ✅ 正常工作 | 23 | — |
| ❌ 死配置 | 10 | 已清除 |
| ⚠️ 默认值不一致 | 1 | 已统一 |
| 🔧 硬编码 | 1 | 已修复 |

### 2. AI 摘要截断修复

**触发：** 日报 AI 摘要内容极少（137 字），末尾被截断。

**根因：** `_llm_call()` `max_tokens=600` 太低，deepseek-v4-flash 输出被硬截断。

**修复：** `max_tokens` 三处提升 + 截断检测日志。

| 位置 | 修复前 | 修复后 |
|------|--------|--------|
| `_llm_call()` 默认 | 600 | 1500 |
| 分块摘要 | 400 | 800 |
| 合并摘要 | 600 | 1500 |

**验证：** 手动生成日报，摘要从 137 字 → 486 字，结构完整无截断。

---

## 超时优先级（最终设计）

```
TIMEOUT_{NAME} 环境变量          ← 单步精确控制
    ↓ 未设
PIPELINE_STEP_TIMEOUT 环境变量   ← 全局兜底 (覆盖所有硬编码)
    ↓ 未设
STEP_TIMEOUTS 硬编码字典         ← 代码内默认值
    ↓ 步骤不在字典中
1800s                            ← 最终兜底
```

---

## 服务器 .env 当前配置

```env
# ---- 调度 ----
SCHEDULE_TIMES=07:00,12:00,18:00,22:00
CNINFO_SCHEDULE_TIME=06:00
STOCK_REPORT_TIME=08:00
STOCK_REPORT_DAYS=15

# ---- Pipeline 超时 ----
PIPELINE_STEP_TIMEOUT=3600
TIMEOUT_CRAWLER=900
TIMEOUT_XWLB=60
TIMEOUT_EXTRACTOR=300
TIMEOUT_DEDUP=900
TIMEOUT_LLM=900
TIMEOUT_EMBEDDING=900
TIMEOUT_QDRANT=1800
TIMEOUT_CNINFO_CRAWL=900
TIMEOUT_CNINFO_EXTRACT=900
TIMEOUT_CNINFO_PDF=900
```

---

## 调度器

| 时间 | 步骤 |
|------|------|
| 06:00 | cninfo 公告管道 |
| 07:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant→**日报** |
| 08:00 | 个股日报 |
| 12:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
| 18:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
| 22:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |

---

## 常用命令

```bash
# 全链路 + 日报
uv run a-share pipeline --once --report

# 仅日报 (指定日期)
/home/pi/.local/bin/uv run python -m scripts.run_scheduler --once --date 20260623 --steps report

# 检索
uv run a-share search "关键词" --top 5

# 同步到服务器
scp <file> pi@192.168.1.160:/home/pi/news/<path>/

# 重启服务
ssh pi@192.168.1.160 "sudo systemctl restart a-share-research"
```

---

## 已知技术债务

| 问题 | 文件 | 状态 |
|------|------|------|
| QdrantClient HTTP 超时硬编码 10s | `vectorstore/client.py:93` | 无可配环境变量 |
| DashScope HTTP 超时硬编码 60s | `embedding/remote.py:81` | 无可配环境变量 |
| 超时解析逻辑在 pipeline.py 和 main.py 各一份 | 两处 | 可抽取公共函数 |
| `deepseek-v4-flash` 概率性返回空 | — | 分块处理 + 失败跳过 |
| wallstreetcn JS 渲染拦截 | — | 正文提取率 0% |
| eeo 列表页仍走 Playwright（仅文章页走 httpx） | `crawler/engine.py` | 列表页目前正常，暂不改动 |

---

## 架构备忘：静态/动态抓取分流

```
SourceConfig.js_render == False 且 wait_for == None
  → _fetch_static() → httpx 直连 (无浏览器, 快, 不被反爬)
  → markdownify + BeautifulSoup (清洗 script/style 后转 Markdown)

SourceConfig.js_render == True 或 wait_for 有值
  → _crawl_once() → Playwright (现有逻辑)
```

`_TimeoutGuard`:
- 同源连续超时 2 次 → `skip=True`
- 后续请求在 `semaphore` 内检查 `guard.skip`，立即返回 `Skipped`
- 一次成功即重置计数器

---

## 记忆

- `never-change-llm-model.md` — 绝不允许修改大模型配置
- `deploy-html.md` — user_guide.html 同步到 Pi 和 Web 服务器
- `sync-sources-yaml.md` — 修改 sources.yaml 前从 Pi 拉取，改完推回
- `update-user-guide.md` — 更新 md+html+部署
