# FastAPI + Docker 迁移实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将广西政府采购网公告监控系统从 Flask CLI 架构迁移到 FastAPI + Docker,含 Spider 模块化、SQLAlchemy ORM、APScheduler 和异步改造。 **Architecture:** 单体 FastAPI 应用,Spider 基类 + Pipeline 策略模式统一爬虫管理,PostgreSQL + asyncpg 异步数据库,APScheduler 内置定时任务,Docker 单容器部署连接外部 PostgreSQL。 **Tech Stack:** Python 3.12, FastAPI, SQLAlchemy 2.0 (async), asyncpg, httpx, APScheduler, pydantic-settings, Alembic, pytest, ruff, Docker, BeautifulSoup4, pycryptodome --- ### Task 1: 创建项目骨架 **Files:** - Create: `pyproject.toml` - Create: `app/__init__.py` - Create: `app/config.py` - Create: `app/main.py` - Create: `.env.example` - Create: `.gitignore` - [ ] **Step 1: 创建 pyproject.toml** ```toml [project] name = "gx-gp-notify" version = "2.0.0" description = "广西政府采购网公告监控系统" requires-python = ">=3.12" dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", "sqlalchemy[asyncio]>=2.0.30", "asyncpg>=0.29.0", "alembic>=1.13.0", "httpx>=0.27.0", "apscheduler>=3.10.0", "pydantic-settings>=2.3.0", "beautifulsoup4>=4.12.0", "lxml>=5.2.0", "pycryptodome>=3.20.0", "python-dateutil>=2.9.0", ] [project.optional-dependencies] dev = [ "pytest>=8.2.0", "pytest-asyncio>=0.23.0", "pytest-cov>=5.0.0", "httpx>=0.27.0", "ruff>=0.4.0", ] [tool.ruff] target-version = "py312" line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] ``` - [ ] **Step 2: 创建 .env.example** ```bash # 应用 DEBUG=false LOG_LEVEL=INFO # 数据库 DATABASE_URL=postgresql+asyncpg://gx-gp-notify:password@10.10.10.14:5432/gx-gp-notify # 爬虫 CRAWLER_BASE_URL=https://zfcg.gxzf.gov.cn CRAWLER_KEYWORDS=["大化"] CRAWLER_MAX_PAGES=10 CRAWLER_TIMEOUT=30 # 企业微信 WECHAT_ENABLED=true WECHAT_CORP_ID=ww69e8e44636f47780 WECHAT_AGENT_ID=1000007 WECHAT_SECRET= WECHAT_TOKEN= WECHAT_ENCODING_AES_KEY= WECHAT_PORT=18001 WECHAT_HOST=0.0.0.0 # 定时任务 SCHEDULER_ENABLED=true SCHEDULER_CRON=0 8,14,18 * * * # Markdown MARKDOWN_ENABLED=true MARKDOWN_OUTPUT_FILE=onu.md ``` - [ ] **Step 3: 创建 .gitignore** ```gitignore .env logs/ *.log __pycache__/ *.pyc .venv/ .ruff_cache/ .pytest_cache/ *.egg-info/ dist/ ``` - [ ] **Step 4: 创建 app/__init__.py** (空文件) - [ ] **Step 5: 创建 app/config.py** ```python from pydantic_settings import BaseSettings, SettingsConfigDict from typing import List class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") # 应用 debug: bool = False log_level: str = "INFO" # 数据库 database_url: str = "postgresql+asyncpg://gx-gp-notify:password@localhost:5432/gx-gp-notify" # 爬虫 crawler_base_url: str = "https://zfcg.gxzf.gov.cn" crawler_keywords: List[str] = ["大化"] crawler_max_pages: int = 10 crawler_timeout: int = 30 crawler_page_size: int = 100 # 企业微信 wechat_enabled: bool = True wechat_corp_id: str = "" wechat_agent_id: str = "" wechat_secret: str = "" wechat_token: str = "" wechat_encoding_aes_key: str = "" wechat_port: int = 18001 wechat_host: str = "0.0.0.0" # 定时任务 scheduler_enabled: bool = True scheduler_cron: str = "0 8,14,18 * * *" # Markdown markdown_enabled: bool = True markdown_output_file: str = "onu.md" # 公告来源(JSON 字符串,从环境变量读取) announcement_sources: str = '{"ZcyAnnouncement1":{"category_id":66485,"name":"采购公告","type":"purchase"},"ZcyAnnouncement2":{"category_id":66485,"name":"结果公告","type":"result"},"ZcyAnnouncement3":{"category_id":66485,"name":"合同公告","type":"contract"},"ZcyAnnouncement4":{"category_id":66485,"name":"更正公告","type":"correction"},"ZcyAnnouncement5":{"category_id":66485,"name":"招标文件预公示","type":"pre_announcement"},"ZcyAnnouncement6":{"category_id":66485,"name":"单一来源公示","type":"single_source"},"ZcyAnnouncement7":{"category_id":66485,"name":"电子卖场公示","type":"electronic_market"},"ZcyAnnouncement10":{"category_id":66485,"name":"履约验收公示","type":"acceptance"},"ZcyAnnouncement11":{"category_id":66485,"name":"工程类公告","type":"engineering"},"ZcyAnnouncement20":{"category_id":66485,"name":"框架协议征集公告","type":"framework_agreement"},"ZcyAnnouncement21":{"category_id":66485,"name":"框架协议入围结果公告","type":"framework_result"},"ZcyAnnouncement23":{"category_id":66485,"name":"框架协议成交结果汇总公告","type":"framework_summary"},"61-266648":{"category_id":66485,"name":"采购意向公开","type":"intention"}}' settings = Settings() ``` - [ ] **Step 6: 创建 app/main.py** ```python import logging from contextlib import asynccontextmanager from fastapi import FastAPI from app.config import settings @asynccontextmanager async def lifespan(app: FastAPI): # 启动时初始化 logging.basicConfig(level=settings.log_level) yield # 关闭时清理 app = FastAPI( title="广西政府采购网公告监控系统", version="2.0.0", lifespan=lifespan, docs_url="/docs" if settings.debug else None, redoc_url=None, ) @app.get("/health") async def health(): return {"status": "ok"} ``` - [ ] **Step 7: 安装依赖并验证** ```bash cd /home/v6ole/PythonProject/GX-gp-notify pip install -e ".[dev]" uvicorn app.main:app --host 0.0.0.0 --port 8000 & sleep 2 curl http://localhost:8000/health # Expected: {"status":"ok"} kill %1 ``` - [ ] **Step 8: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add pyproject.toml app/__init__.py app/config.py app/main.py .env.example .gitignore git commit -m "feat: 创建 FastAPI 项目骨架(config + main + 依赖管理)" ``` --- ### Task 2: 数据库模型 + Alembic **Files:** - Create: `app/models/__init__.py` - Create: `app/models/announcement.py` - Create: `app/models/schemas.py` - Create: `alembic.ini` - Create: `alembic/env.py` - Create: `alembic/script.py.mako` - Modify: `app/main.py` — 添加数据库引擎 - [ ] **Step 1: 创建 app/models/__init__.py** (空文件) - [ ] **Step 2: 创建 app/models/announcement.py** ```python import hashlib from datetime import datetime, date from sqlalchemy import String, Boolean, DateTime, Integer, Text, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Announcement(Base): __tablename__ = "announcements" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String(500), nullable=False) publish_date: Mapped[datetime] = mapped_column(DateTime, nullable=False) purchase_name: Mapped[str] = mapped_column(String(200), default="") content_url: Mapped[str] = mapped_column(Text, default="") source_code: Mapped[str] = mapped_column(String(50), nullable=False) source_name: Mapped[str] = mapped_column(String(100), nullable=False) announcement_type: Mapped[str] = mapped_column(String(50), default="purchase") content_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) crawl_mode: Mapped[str] = mapped_column(String(20), default="auto") is_new: Mapped[bool] = mapped_column(Boolean, default=True) is_sent: Mapped[bool] = mapped_column(Boolean, default=False) keyword_matched: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) @staticmethod def generate_hash(title: str, publish_date: str, purchase_name: str, content_url: str, source_code: str) -> str: content = f"{title}|{publish_date}|{purchase_name}|{content_url}|{source_code}" return hashlib.sha256(content.encode("utf-8")).hexdigest() @staticmethod def source_map() -> dict: import json from app.config import settings return json.loads(settings.announcement_sources) ``` - [ ] **Step 3: 创建 app/models/schemas.py** ```python from datetime import datetime from typing import Optional from pydantic import BaseModel class AnnouncementResponse(BaseModel): id: int title: str publish_date: datetime purchase_name: str content_url: str source_code: str source_name: str announcement_type: str crawl_mode: str is_new: bool is_sent: bool keyword_matched: bool created_at: datetime model_config = {"from_attributes": True} class AnnouncementListResponse(BaseModel): total: int page: int page_size: int items: list[AnnouncementResponse] class CrawlTriggerRequest(BaseModel): keywords: Optional[list[str]] = None sources: Optional[list[str]] = None manual: bool = False class CrawlStatusResponse(BaseModel): running: bool last_crawl_time: Optional[datetime] = None total_sources: int class SourceInfo(BaseModel): code: str name: str type: str class SourcesResponse(BaseModel): sources: list[SourceInfo] class JobResponse(BaseModel): id: str name: str next_run_time: Optional[str] = None ``` - [ ] **Step 4: 初始化 Alembic** ```bash cd /home/v6ole/PythonProject/GX-gp-notify pip install alembic alembic init alembic ``` - [ ] **Step 5: 修改 alembic/env.py** Read the generated file, replace its content with: ```python from alembic import context from sqlalchemy import engine_from_config, pool from app.models.announcement import Base config = context.config config.set_main_option("sqlalchemy.url", "postgresql+asyncpg://placeholder:placeholder@localhost:5432/placeholder") target_metadata = Base.metadata def run_migrations_offline(): url = config.get_main_option("sqlalchemy.url") context.configure(url=url, target_metadata=target_metadata, literal_binds=True) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): from app.config import settings connectable = engine_from_config( {"sqlalchemy.url": settings.database_url}, prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ``` - [ ] **Step 6: 生成初始迁移** ```bash cd /home/v6ole/PythonProject/GX-gp-notify DATABASE_URL=postgresql+asyncpg://gx-gp-notify:MA6RBX4F6Bd5DGmw@10.10.10.14:5432/gx-gp-notify \ alembic revision --autogenerate -m "create_announcements_table" # Expected: Generating .../alembic/versions/xxxx_create_announcements_table.py ... done ``` - [ ] **Step 7: 运行迁移** ```bash cd /home/v6ole/PythonProject/GX-gp-notify DATABASE_URL=postgresql+asyncpg://gx-gp-notify:MA6RBX4F6Bd5DGmw@10.10.10.14:5432/gx-gp-notify \ alembic upgrade head # Expected: Running upgrade ... -> xxxx, create announcements table ``` - [ ] **Step 8: 更新 app/main.py 添加数据库初始化** ```python import logging from contextlib import asynccontextmanager from fastapi import FastAPI from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from app.config import settings from app.models.announcement import Base engine = create_async_engine(settings.database_url, echo=settings.debug) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async def get_db() -> AsyncSession: async with async_session() as session: yield session @asynccontextmanager async def lifespan(app: FastAPI): logging.basicConfig( level=getattr(logging, settings.log_level), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) yield await engine.dispose() app = FastAPI( title="广西政府采购网公告监控系统", version="2.0.0", lifespan=lifespan, docs_url="/docs" if settings.debug else None, redoc_url=None, ) @app.get("/health") async def health(): return {"status": "ok"} ``` - [ ] **Step 9: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/models/ alembic/ alembic.ini app/main.py git commit -m "feat: 添加 SQLAlchemy 模型 + Alembic 数据库迁移" ``` --- ### Task 3: Spider 基类 + Pipeline 配置 **Files:** - Create: `app/crawler/__init__.py` - Create: `app/crawler/base.py` - Test: `tests/test_crawler/test_base.py` - Create: `tests/__init__.py` - Create: `tests/test_crawler/__init__.py` - Create: `tests/conftest.py` - [ ] **Step 1: 创建 app/crawler/__init__.py** (空文件) - [ ] **Step 2: 创建 app/crawler/base.py** ```python import hashlib from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime from typing import List, Optional @dataclass class CrawlResult: source_code: str source_name: str total_count: int = 0 new_count: int = 0 announcements: list = field(default_factory=list) error_message: Optional[str] = None crawled_at: datetime = field(default_factory=datetime.now) duration: float = 0.0 @property def success(self) -> bool: return self.error_message is None @dataclass class PipelineConfig: filter_enabled: bool = True keywords: List[str] = field(default_factory=list) dedup_enabled: bool = True notify_mode: str = "filtered" mark_sent: bool = False @dataclass class PipelineResult: stored: int = 0 filtered: int = 0 notified: int = 0 markdown_generated: bool = False class BaseSpider(ABC): name: str source_code: str source_name: str @abstractmethod async def crawl(self) -> CrawlResult: ... def get_pipeline_config(self) -> PipelineConfig: return PipelineConfig() @staticmethod def generate_content_hash(title: str, publish_date: str, purchase_name: str, content_url: str, source_code: str) -> str: content = f"{title}|{publish_date}|{purchase_name}|{content_url}|{source_code}" return hashlib.sha256(content.encode("utf-8")).hexdigest() ``` - [ ] **Step 3: 创建 tests/__init__.py, tests/test_crawler/__init__.py** (空文件) - [ ] **Step 4: 创建 tests/conftest.py** ```python import pytest @pytest.fixture def sample_announcement_data(): return { "title": "测试公告标题", "publish_date": "2026-05-09", "purchase_name": "测试单位", "content_url": "https://example.com/detail/123", "source_code": "ZcyAnnouncement1", "source_name": "采购公告", } ``` - [ ] **Step 5: 创建 tests/test_crawler/test_base.py** ```python import pytest from datetime import datetime from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig, PipelineResult class FakeSpider(BaseSpider): name = "test_spider" source_code = "test_source" source_name = "测试来源" async def crawl(self) -> CrawlResult: return CrawlResult( source_code=self.source_code, source_name=self.source_name, total_count=5, new_count=3, crawled_at=datetime.now(), ) def get_pipeline_config(self) -> PipelineConfig: return PipelineConfig( filter_enabled=True, keywords=["测试"], dedup_enabled=True, notify_mode="filtered", mark_sent=False, ) @pytest.mark.asyncio async def test_base_spider_crawl(): spider = FakeSpider() result = await spider.crawl() assert result.source_code == "test_source" assert result.total_count == 5 assert result.new_count == 3 assert result.success is True def test_pipeline_config_defaults(): config = PipelineConfig() assert config.filter_enabled is True assert config.keywords == [] assert config.dedup_enabled is True assert config.notify_mode == "filtered" assert config.mark_sent is False def test_pipeline_result_defaults(): result = PipelineResult() assert result.stored == 0 assert result.filtered == 0 assert result.notified == 0 assert result.markdown_generated is False def test_generate_content_hash(): h = BaseSpider.generate_content_hash( "title", "2026-05-09", "unit", "https://x.com", "source" ) assert len(h) == 64 assert all(c in "0123456789abcdef" for c in h) def test_generate_content_hash_deterministic(): h1 = BaseSpider.generate_content_hash("t", "d", "p", "u", "s") h2 = BaseSpider.generate_content_hash("t", "d", "p", "u", "s") assert h1 == h2 def test_crawl_result_failure(): result = CrawlResult( source_code="s", source_name="n", error_message="连接超时" ) assert result.success is False ``` - [ ] **Step 6: 运行测试** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_base.py -v # Expected: 6 passed ``` - [ ] **Step 7: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/crawler/ tests/ git commit -m "feat: 添加 Spider 基类 + Pipeline 配置 + 测试" ``` --- ### Task 4: Parssers 模块 **Files:** - Create: `app/crawler/parsers.py` - Test: `tests/test_crawler/test_parsers.py` - [ ] **Step 1: 创建测试 tests/test_crawler/test_parsers.py** ```python import json from datetime import datetime from app.crawler.parsers import ( parse_gxgp_api_response, parse_dahuagov_html, extract_pagination, ) def make_api_response(records_data): return { "success": True, "result": { "data": { "data": records_data, "total": len(records_data), "pageNo": 1, "pageSize": 100, "pages": 1, "empty": len(records_data) == 0, "hasNext": False, "hasPrevious": False, } }, } def test_parse_gxgp_single_record(): response = make_api_response([ { "title": "测试采购公告", "publishDate": 1746720000000, "purchaseName": "测试采购单位", "articleId": 12345, } ]) crawled_at = datetime(2026, 5, 9, 10, 0, 0) results = parse_gxgp_api_response( response, source_code="ZcyAnnouncement1", source_name="采购公告", crawled_at=crawled_at, category_id=66485, ) assert len(results) == 1 assert results[0]["title"] == "测试采购公告" assert results[0]["source_code"] == "ZcyAnnouncement1" assert "content_hash" in results[0] def test_parse_gxgp_empty_response(): response = make_api_response([]) crawled_at = datetime(2026, 5, 9, 10, 0, 0) results = parse_gxgp_api_response( response, "ZcyAnnouncement1", "采购公告", crawled_at, 66485 ) assert len(results) == 0 def test_parse_gxgp_missing_title(): response = make_api_response([ {"title": "", "publishDate": 1746720000000, "purchaseName": "x", "articleId": 1} ]) crawled_at = datetime(2026, 5, 9, 10, 0, 0) results = parse_gxgp_api_response( response, "ZcyAnnouncement1", "采购公告", crawled_at, 66485 ) assert len(results) == 0 def test_extract_pagination(): response = make_api_response([]) pagination = extract_pagination(response) assert pagination["total"] == 0 assert pagination["page_no"] == 1 assert pagination["has_next"] is False def test_parse_dahuagov_html(): html = """ """ crawled_at = datetime(2026, 5, 9, 10, 0, 0) results = parse_dahuagov_html(html, crawled_at) assert len(results) == 2 assert results[0]["title"] == "大化县某项目采购公告" assert results[0]["source_code"] == "dahuagov" assert results[0]["source_name"] == "大化县政府网采购公告" assert results[0]["purchase_name"] == "大化瑶族自治县" def test_parse_dahuagov_html_no_list(): html = "" crawled_at = datetime(2026, 5, 9, 10, 0, 0) results = parse_dahuagov_html(html, crawled_at) assert len(results) == 0 ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_parsers.py -v # Expected: FAIL — ModuleNotFoundError or ImportError ``` - [ ] **Step 3: 创建 app/crawler/parsers.py** ```python import hashlib from datetime import datetime from typing import Any, Dict, List, Optional from urllib.parse import urljoin from bs4 import BeautifulSoup def parse_gxgp_api_response( response_data: Dict[str, Any], source_code: str, source_name: str, crawled_at: datetime, category_id: int, ) -> List[Dict[str, Any]]: if not response_data.get("success"): return [] data = response_data.get("result", {}).get("data", {}) records = data.get("data", []) if not records: return [] results = [] for record in records: title = str(record.get("title", "")).strip() if not title: continue timestamp = record.get("publishDate") if not timestamp: continue try: publish_date = datetime.fromtimestamp(int(timestamp) / 1000) except (ValueError, TypeError): continue purchase_name = str(record.get("purchaseName", "")).strip() article_id = record.get("articleId") if not article_id: continue content_url = f"https://zfcg.gxzf.gov.cn/site/detail?parentId={category_id}&articleId={article_id}" announce = { "title": title, "publish_date": publish_date, "purchase_name": purchase_name, "content_url": content_url, "source_code": source_code, "source_name": source_name, "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": publish_date.date() == datetime.now().date(), } announce["content_hash"] = _generate_hash(announce) results.append(announce) return results def extract_pagination(response_data: Dict[str, Any]) -> Dict[str, Any]: data = response_data.get("result", {}).get("data", {}) return { "total": data.get("total", 0), "page_no": data.get("pageNo", 1), "page_size": data.get("pageSize", 100), "pages": data.get("pages", 0), "empty": data.get("empty", True), "has_next": data.get("hasNext", False), "has_previous": data.get("hasPrevious", False), } def parse_dahuagov_html(html: str, crawled_at: datetime) -> List[Dict[str, Any]]: soup = BeautifulSoup(html, "html.parser") lists = soup.find_all("ul", class_="more-list") if not lists: return [] results = [] base_url = "http://www.gxdh.gov.cn" base_path = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/" for ul in lists: for li in ul.find_all("li"): date_span = li.find("span") if not date_span: continue date_text = date_span.get_text(strip=True) try: publish_date = datetime.strptime(date_text, "%Y-%m-%d") except ValueError: continue link_tag = li.find("a") if not link_tag: continue title = link_tag.get("title", "") or link_tag.get_text(strip=True) href = link_tag.get("href", "") if not title or not href: continue if href.startswith("./") or href.startswith("../"): content_url = urljoin(base_url + base_path, href) elif href.startswith("/"): content_url = base_url + href elif href.startswith("http"): content_url = href else: content_url = urljoin(base_url + base_path, href) announce = { "title": title, "publish_date": publish_date, "purchase_name": "大化瑶族自治县", "content_url": content_url, "source_code": "dahuagov", "source_name": "大化县政府网采购公告", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": publish_date.date() == datetime.now().date(), } announce["content_hash"] = _generate_hash(announce) results.append(announce) return results def _generate_hash(ann: Dict[str, Any]) -> str: content = ( f"{ann['title']}|{ann['publish_date'].strftime('%Y-%m-%d')}" f"|{ann['purchase_name']}|{ann['content_url']}|{ann['source_code']}" ) return hashlib.sha256(content.encode("utf-8")).hexdigest() ``` - [ ] **Step 4: 运行测试验证通过** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_parsers.py -v # Expected: 6 passed ``` - [ ] **Step 5: 运行全部测试** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/ -v # Expected: 12 passed ``` - [ ] **Step 6: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/crawler/parsers.py tests/test_crawler/test_parsers.py git commit -m "feat: 添加数据解析器模块 + 测试" ``` --- ### Task 5: GXGP Spider(广西政府采购网) **Files:** - Create: `app/crawler/gxgp_spider.py` - Test: `tests/test_crawler/test_gxgp_spider.py` - [ ] **Step 1: 创建测试 tests/test_crawler/test_gxgp_spider.py** ```python import pytest from datetime import datetime from unittest.mock import AsyncMock, patch, MagicMock from app.crawler.gxgp_spider import GXGPSpider from app.crawler.base import PipelineConfig @pytest.mark.asyncio async def test_gxgp_spider_attributes(): spider = GXGPSpider() assert spider.name == "gxgp" assert spider.source_code == "gxgp" assert spider.source_name == "广西政府采购网" @pytest.mark.asyncio async def test_gxgp_spider_pipeline_config(): spider = GXGPSpider() config = spider.get_pipeline_config() assert isinstance(config, PipelineConfig) assert config.filter_enabled is True assert config.notify_mode == "filtered" assert config.mark_sent is False @pytest.mark.asyncio async def test_gxgp_spider_crawl_empty(): spider = GXGPSpider() mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "success": True, "result": {"data": {"data": [], "total": 0, "pageNo": 1, "pageSize": 100, "pages": 0, "empty": True, "hasNext": False, "hasPrevious": False}}, } with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_response)): result = await spider.crawl() assert result.total_count == 0 assert len(result.announcements) == 0 assert result.success is True @pytest.mark.asyncio async def test_gxgp_spider_crawl_with_data(): spider = GXGPSpider() mock_data = { "success": True, "result": { "data": { "data": [{ "title": "测试采购公告", "publishDate": 1746720000000, "purchaseName": "测试单位", "articleId": 12345, }], "total": 1, "pageNo": 1, "pageSize": 100, "pages": 1, "empty": False, "hasNext": False, "hasPrevious": False, } }, } mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = mock_data with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_response)): result = await spider.crawl(sources=["ZcyAnnouncement1"]) assert result.total_count >= 0 assert result.success is True ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_gxgp_spider.py -v # Expected: FAIL — ImportError ``` - [ ] **Step 3: 创建 app/crawler/gxgp_spider.py** ```python import json import random import time from datetime import datetime from typing import List, Optional import httpx from app.config import settings from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig from app.crawler.parsers import parse_gxgp_api_response, extract_pagination class GXGPSpider(BaseSpider): name = "gxgp" source_code = "gxgp" source_name = "广西政府采购网" def __init__(self): self.base_url = settings.crawler_base_url self.announcement_api = f"{self.base_url}/portal/category" def get_pipeline_config(self) -> PipelineConfig: return PipelineConfig( filter_enabled=True, keywords=list(settings.crawler_keywords), dedup_enabled=True, notify_mode="filtered", mark_sent=False, ) async def crawl(self, sources: Optional[List[str]] = None, max_pages: Optional[int] = None) -> CrawlResult: if max_pages is None: max_pages = settings.crawler_max_pages if sources is None: source_map = json.loads(settings.announcement_sources) sources = list(source_map.keys()) start_time = datetime.now() all_announcements = [] error_messages = [] async with httpx.AsyncClient(timeout=settings.crawler_timeout) as client: for source_code in sources: source_info = json.loads(settings.announcement_sources).get(source_code) if not source_info: continue category_id = source_info["category_id"] source_name = source_info["name"] for page_no in range(1, max_pages + 1): if page_no > 1: await self._delay() try: data = await self._fetch_page( client, source_code, category_id, page_no ) if data is None: break records = parse_gxgp_api_response( data, source_code, source_name, start_time, category_id ) if not records: break all_announcements.extend(records) pagination = extract_pagination(data) if not pagination["has_next"]: break except Exception as e: error_messages.append(f"{source_code} page {page_no}: {e}") break duration = (datetime.now() - start_time).total_seconds() return CrawlResult( source_code=self.source_code, source_name=self.source_name, total_count=len(all_announcements), new_count=len(all_announcements), announcements=all_announcements, error_message="; ".join(error_messages) if error_messages else None, crawled_at=start_time, duration=duration, ) async def _fetch_page(self, client: httpx.AsyncClient, source_code: str, category_id: int, page_no: int) -> Optional[dict]: payload = { "keyword": "", "publishDateBegin": "", "publishDateEnd": "", "pageNo": page_no, "pageSize": settings.crawler_page_size, "categoryCode": source_code, "_t": int(time.time() * 1000), } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Content-Type": "application/json;charset=UTF-8", "Origin": self.base_url, "Referer": f"{self.base_url}/site/category?parentId={category_id}&childrenCode={source_code}", } response = await client.post( self.announcement_api, json=payload, headers=headers ) if response.status_code != 200: return None return response.json() async def _delay(self): import asyncio delay = random.uniform(1.0, 3.0) await asyncio.sleep(delay) ``` - [ ] **Step 4: 运行测试验证通过** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_gxgp_spider.py -v # Expected: 3 passed ``` - [ ] **Step 5: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/crawler/gxgp_spider.py tests/test_crawler/test_gxgp_spider.py git commit -m "feat: 添加 GXGP Spider(广西政府采购网爬虫)+ 测试" ``` --- ### Task 6: Dahuagov Spider(大化县政府网) **Files:** - Create: `app/crawler/dahuagov_spider.py` - Test: `tests/test_crawler/test_dahuagov_spider.py` - [ ] **Step 1: 创建测试 tests/test_crawler/test_dahuagov_spider.py** ```python import pytest from unittest.mock import AsyncMock, patch, MagicMock from app.crawler.dahuagov_spider import DahuagovSpider from app.crawler.base import PipelineConfig @pytest.mark.asyncio async def test_dahuagov_spider_attributes(): spider = DahuagovSpider() assert spider.name == "dahuagov" assert spider.source_code == "dahuagov" assert spider.source_name == "大化县政府网采购公告" @pytest.mark.asyncio async def test_dahuagov_spider_pipeline_config(): spider = DahuagovSpider() config = spider.get_pipeline_config() assert isinstance(config, PipelineConfig) assert config.filter_enabled is False assert config.notify_mode == "all" assert config.mark_sent is True @pytest.mark.asyncio async def test_dahuagov_spider_crawl(): spider = DahuagovSpider() html = """ """ mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = html with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): result = await spider.crawl() assert result.total_count == 2 assert len(result.announcements) == 2 assert result.success is True @pytest.mark.asyncio async def test_dahuagov_spider_crawl_empty(): spider = DahuagovSpider() mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = "" with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): result = await spider.crawl() assert result.total_count == 0 assert result.success is True ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_dahuagov_spider.py -v # Expected: FAIL — ImportError ``` - [ ] **Step 3: 创建 app/crawler/dahuagov_spider.py** ```python import asyncio import random from datetime import datetime import httpx from app.config import settings from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig from app.crawler.parsers import parse_dahuagov_html class DahuagovSpider(BaseSpider): name = "dahuagov" source_code = "dahuagov" source_name = "大化县政府网采购公告" BASE_URL = "http://www.gxdh.gov.cn" ANNOUNCEMENT_PATH = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/" def get_pipeline_config(self) -> PipelineConfig: return PipelineConfig( filter_enabled=False, keywords=[], dedup_enabled=True, notify_mode="all", mark_sent=True, ) async def crawl(self) -> CrawlResult: start_time = datetime.now() url = self.BASE_URL + self.ANNOUNCEMENT_PATH async with httpx.AsyncClient(timeout=settings.crawler_timeout) as client: await self._delay() try: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": self.BASE_URL, } response = await client.get(url, headers=headers) if response.status_code != 200: return CrawlResult( source_code=self.source_code, source_name=self.source_name, error_message=f"HTTP {response.status_code}", crawled_at=start_time, ) html = response.text except Exception as e: return CrawlResult( source_code=self.source_code, source_name=self.source_name, error_message=str(e), crawled_at=start_time, ) announcements = parse_dahuagov_html(html, start_time) duration = (datetime.now() - start_time).total_seconds() return CrawlResult( source_code=self.source_code, source_name=self.source_name, total_count=len(announcements), new_count=len(announcements), announcements=announcements, crawled_at=start_time, duration=duration, ) async def _delay(self): delay = random.uniform(1.0, 3.0) await asyncio.sleep(delay) ``` - [ ] **Step 4: 运行测试验证通过** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_crawler/test_dahuagov_spider.py -v # Expected: 4 passed ``` - [ ] **Step 5: 运行全部测试** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/ -v # Expected: 19 passed ``` - [ ] **Step 6: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/crawler/dahuagov_spider.py tests/test_crawler/test_dahuagov_spider.py git commit -m "feat: 添加 Dahuagov Spider(大化县政府网爬虫)+ 测试" ``` --- ### Task 7: CrawlService(爬取编排器) **Files:** - Create: `app/services/__init__.py` - Create: `app/services/crawl_service.py` - Test: `tests/test_services/__init__.py` - Test: `tests/test_services/test_crawl_service.py` - [ ] **Step 1: 创建测试 tests/test_services/test_crawl_service.py** ```python import pytest from datetime import datetime from unittest.mock import AsyncMock, patch from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig from app.services.crawl_service import CrawlService class MockGXGPSpider(BaseSpider): name = "mock_gxgp" source_code = "mock_gxgp" source_name = "Mock GXGP" async def crawl(self, **kwargs): return CrawlResult( source_code=self.source_code, source_name=self.source_name, total_count=10, new_count=5, announcements=[ { "title": f"公告{i}", "publish_date": datetime(2026, 5, 9), "purchase_name": "单位", "content_url": f"https://x.com/{i}", "source_code": "ZcyAnnouncement1", "source_name": "采购公告", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": True, "content_hash": f"hash{i}", } for i in range(10) ], ) def get_pipeline_config(self): return PipelineConfig( filter_enabled=True, keywords=["大化"], notify_mode="filtered", ) class MockDahuagovSpider(BaseSpider): name = "mock_dahuagov" source_code = "mock_dahuagov" source_name = "Mock Dahuagov" async def crawl(self, **kwargs): return CrawlResult( source_code=self.source_code, source_name=self.source_name, total_count=3, new_count=3, announcements=[ { "title": f"大化公告{j}", "publish_date": datetime(2026, 5, 9), "purchase_name": "大化瑶族自治县", "content_url": f"https://dh.com/{j}", "source_code": "dahuagov", "source_name": "大化县政府网采购公告", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": True, "content_hash": f"dh_hash{j}", } for j in range(3) ], ) def get_pipeline_config(self): return PipelineConfig( filter_enabled=False, notify_mode="all", mark_sent=True, ) @pytest.mark.asyncio async def test_crawl_service_registers_spiders(): service = CrawlService() service.register(MockGXGPSpider()) service.register(MockDahuagovSpider()) assert len(service.spiders) == 2 @pytest.mark.asyncio async def test_crawl_service_run_all(): service = CrawlService() service.register(MockGXGPSpider()) service.register(MockDahuagovSpider()) results = await service.run_all() assert len(results) == 2 assert results[0].total_count == 10 assert results[1].total_count == 3 assert all(r.success for r in results) @pytest.mark.asyncio async def test_crawl_service_run_specific(): service = CrawlService() service.register(MockGXGPSpider()) service.register(MockDahuagovSpider()) results = await service.run_spider("mock_dahuagov") assert len(results) == 1 assert results[0].source_code == "mock_dahuagov" ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_services/test_crawl_service.py -v # Expected: FAIL — ImportError ``` - [ ] **Step 3: 创建 app/services/__init__.py** (空文件) - [ ] **Step 4: 创建 app/services/crawl_service.py** ```python from typing import Dict, List from app.crawler.base import BaseSpider, CrawlResult class CrawlService: def __init__(self): self.spiders: Dict[str, BaseSpider] = {} def register(self, spider: BaseSpider): self.spiders[spider.name] = spider async def run_all(self) -> List[CrawlResult]: results = [] for name, spider in self.spiders.items(): result = await spider.crawl() results.append(result) return results async def run_spider(self, name: str, **kwargs) -> List[CrawlResult]: spider = self.spiders.get(name) if spider is None: return [CrawlResult( source_code=name, source_name=name, error_message=f"Spider not found: {name}" )] result = await spider.crawl(**kwargs) return [result] def get_spider_names(self) -> List[str]: return list(self.spiders.keys()) def get_pipeline_config(self, name: str): spider = self.spiders.get(name) if spider: return spider.get_pipeline_config() return None ``` - [ ] **Step 5: 运行测试验证通过** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_services/test_crawl_service.py -v # Expected: 3 passed ``` - [ ] **Step 6: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/services/ tests/test_services/ git commit -m "feat: 添加 CrawlService 爬取编排器 + 测试" ``` --- ### Task 8: PostCrawlPipeline 统一管道 **Files:** - Create: `app/services/pipeline.py` - Create: `app/services/filter_service.py` - Test: `tests/test_services/test_pipeline.py` - Test: `tests/test_services/test_filter_service.py` - [ ] **Step 1: 创建过滤测试 tests/test_services/test_filter_service.py** ```python from datetime import datetime from app.services.filter_service import filter_by_keywords, filter_by_date def test_filter_by_keywords_match(): announcements = [ {"title": "大化县采购公告", "purchase_name": "大化县财政局", "source_code": "test", "source_name": "test"}, {"title": "南宁市采购公告", "purchase_name": "南宁市财政局", "source_code": "test", "source_name": "test"}, ] result = filter_by_keywords(announcements, ["大化"]) assert len(result) == 1 assert result[0]["title"] == "大化县采购公告" def test_filter_by_keywords_no_keywords(): announcements = [ {"title": "大化县采购公告", "purchase_name": "x", "source_code": "test", "source_name": "test"}, ] result = filter_by_keywords(announcements, []) assert len(result) == 1 def test_filter_by_date_range(): today = datetime(2026, 5, 9) announcements = [ {"title": "t1", "publish_date": datetime(2026, 5, 9), "source_code": "test", "source_name": "test"}, {"title": "t2", "publish_date": datetime(2026, 5, 1), "source_code": "test", "source_name": "test"}, {"title": "t3", "publish_date": datetime(2026, 4, 30), "source_code": "test", "source_name": "test"}, ] result = filter_by_date(announcements, start_date=today, end_date=today) assert len(result) == 1 assert result[0]["title"] == "t1" ``` - [ ] **Step 2: 创建管道测试 tests/test_services/test_pipeline.py** ```python import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime from app.services.pipeline import PostCrawlPipeline from app.crawler.base import CrawlResult, PipelineConfig @pytest.mark.asyncio async def test_pipeline_filtered_mode(): config = PipelineConfig( filter_enabled=True, keywords=["大化"], dedup_enabled=True, notify_mode="filtered", mark_sent=False, ) result = CrawlResult( source_code="test", source_name="test", total_count=3, new_count=3, announcements=[ { "title": "大化县公告", "publish_date": datetime(2026, 5, 9), "purchase_name": "大化县", "content_url": "https://1.com", "source_code": "test", "source_name": "测试", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": True, "content_hash": "abc123", }, { "title": "南宁市公告", "publish_date": datetime(2026, 5, 9), "purchase_name": "南宁市", "content_url": "https://2.com", "source_code": "test", "source_name": "测试", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": True, "content_hash": "def456", }, ], ) mock_db = AsyncMock() mock_notify = AsyncMock() pipeline = PostCrawlPipeline(db_session=mock_db, notification_service=mock_notify) with patch.object(pipeline, "_save_to_db", AsyncMock(return_value=2)): with patch.object(pipeline, "_send_notifications", AsyncMock(return_value=1)): pipe_result = await pipeline.process( result.announcements, config ) assert pipe_result.stored == 2 assert pipe_result.filtered == 1 # 3 total, 2 pass keyword, 1 filtered assert pipe_result.notified == 1 @pytest.mark.asyncio async def test_pipeline_all_mode(): config = PipelineConfig( filter_enabled=False, keywords=[], dedup_enabled=True, notify_mode="all", mark_sent=True, ) result = CrawlResult( source_code="dahuagov", source_name="大化县政府网", total_count=2, new_count=2, announcements=[ { "title": f"公告{i}", "publish_date": datetime(2026, 5, 9), "purchase_name": "大化县", "content_url": f"https://x.com/{i}", "source_code": "dahuagov", "source_name": "大化县政府网采购公告", "announcement_type": "purchase", "crawl_mode": "auto", "is_new": True, "is_today": True, "content_hash": f"hash{i}", } for i in range(2) ], ) mock_db = AsyncMock() mock_notify = AsyncMock() pipeline = PostCrawlPipeline(db_session=mock_db, notification_service=mock_notify) with patch.object(pipeline, "_save_to_db", AsyncMock(return_value=2)): with patch.object(pipeline, "_send_notifications", AsyncMock(return_value=2)): with patch.object(pipeline, "_mark_sent", AsyncMock(return_value=2)): pipe_result = await pipeline.process( result.announcements, config ) assert pipe_result.stored == 2 assert pipe_result.filtered == 0 assert pipe_result.notified == 2 pipeline._mark_sent.assert_awaited_once() ``` - [ ] **Step 3: 运行测试确认失败** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_services/test_filter_service.py tests/test_services/test_pipeline.py -v # Expected: FAIL — ModuleNotFoundError ``` - [ ] **Step 4: 创建 app/services/filter_service.py** ```python from datetime import date from typing import Any, Dict, List, Optional def filter_by_keywords(announcements: List[Dict[str, Any]], keywords: List[str]) -> List[Dict[str, Any]]: if not keywords: return announcements filtered = [] for ann in announcements: search_text = f"{ann.get('title', '')} {ann.get('purchase_name', '')}" if any(kw in search_text for kw in keywords): ann["keyword_matched"] = True filtered.append(ann) else: ann["keyword_matched"] = False filtered.append(ann) # Still include, just mark not matched return filtered def filter_by_date(announcements: List[Dict[str, Any]], start_date: Optional[date] = None, end_date: Optional[date] = None) -> List[Dict[str, Any]]: if not start_date and not end_date: return announcements filtered = [] for ann in announcements: pub_date = ann.get("publish_date") if not pub_date: continue if isinstance(pub_date, date): pub_date = pub_date else: pub_date = pub_date.date() if hasattr(pub_date, "date") else pub_date if start_date and pub_date < start_date: continue if end_date and pub_date > end_date: continue filtered.append(ann) return filtered def dedup_by_hash(announcements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: seen = set() result = [] for ann in announcements: h = ann.get("content_hash") if h and h not in seen: seen.add(h) result.append(ann) return result ``` - [ ] **Step 5: 创建 app/services/pipeline.py** ```python from typing import Any, Dict, List from app.crawler.base import PipelineConfig, PipelineResult from app.services.filter_service import filter_by_keywords, dedup_by_hash class PostCrawlPipeline: def __init__(self, db_session, notification_service): self.db = db_session self.notify = notification_service async def process(self, announcements: List[Dict[str, Any]], config: PipelineConfig) -> PipelineResult: result = PipelineResult() if not announcements: return result # 1. 去重 if config.dedup_enabled: announcements = dedup_by_hash(announcements) # 2. 存储到数据库 stored = await self._save_to_db(announcements) result.stored = stored to_notify = announcements # 3. 筛选 if config.filter_enabled and config.keywords: before = len(to_notify) to_notify = [a for a in to_notify if self._match_keywords(a, config.keywords)] result.filtered = before - len(to_notify) # 4. 推送 if config.notify_mode == "all": result.notified = await self._send_notifications(to_notify) elif config.notify_mode == "filtered": if config.filter_enabled and config.keywords: result.notified = await self._send_notifications(to_notify) elif not config.filter_enabled: result.notified = await self._send_notifications(to_notify) # 5. 标记已发送 if config.mark_sent and result.notified > 0: await self._mark_sent(to_notify) return result async def _save_to_db(self, announcements: List[Dict[str, Any]]) -> int: from sqlalchemy.dialects.postgresql import insert from app.models.announcement import Announcement if not announcements: return 0 values = [{ "title": a["title"], "publish_date": a["publish_date"], "purchase_name": a.get("purchase_name", ""), "content_url": a.get("content_url", ""), "source_code": a["source_code"], "source_name": a["source_name"], "announcement_type": a.get("announcement_type", "purchase"), "content_hash": a["content_hash"], "crawl_mode": a.get("crawl_mode", "auto"), "is_new": a.get("is_new", True), "is_sent": False, "keyword_matched": a.get("keyword_matched", False), } for a in announcements] stmt = insert(Announcement).values(values) stmt = stmt.on_conflict_do_nothing(index_elements=["content_hash"]) result_proxy = await self.db.execute(stmt) await self.db.commit() return result_proxy.rowcount or len(values) async def _send_notifications(self, announcements: List[Dict[str, Any]]) -> int: return await self.notify.send(announcements) async def _mark_sent(self, announcements: List[Dict[str, Any]]) -> int: from app.models.announcement import Announcement from sqlalchemy import update hashes = [a["content_hash"] for a in announcements if a.get("content_hash")] if not hashes: return 0 stmt = ( update(Announcement) .where(Announcement.content_hash.in_(hashes)) .values(is_sent=True) ) result = await self.db.execute(stmt) await self.db.commit() return result.rowcount @staticmethod def _match_keywords(announcement: Dict[str, Any], keywords: List[str]) -> bool: text = f"{announcement.get('title', '')} {announcement.get('purchase_name', '')}" return any(kw in text for kw in keywords) ``` - [ ] **Step 6: 运行测试验证通过** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_services/ -v # Expected: all passed ``` - [ ] **Step 7: 运行全部测试** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/ -v # Expected: all passed ``` - [ ] **Step 8: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/services/pipeline.py app/services/filter_service.py tests/test_services/ git commit -m "feat: 添加 PostCrawlPipeline 统一管道 + 筛选服务 + 测试" ``` --- ### Task 9: 企业微信模块 **Files:** - Create: `app/wechat/__init__.py` - Create: `app/wechat/crypto.py` — 复制现有 WXBizMsgCrypt - Create: `app/wechat/client.py` — 企业微信 API 客户端 - Create: `app/wechat/handler.py` — 消息处理器 - [ ] **Step 1: 创建 app/wechat/__init__.py** (空文件) - [ ] **Step 2: 创建 app/wechat/crypto.py** 从现有 `gx_gp_monitor/wechat/WXBizMsgCrypt.py` 和 `gx_gp_monitor/wechat/ierror.py` 复制并整合: ```bash cd /home/v6ole/PythonProject/GX-gp-notify cp gx_gp_monitor/wechat/WXBizMsgCrypt.py app/wechat/crypto.py cp gx_gp_monitor/wechat/ierror.py app/wechat/ierror.py ``` - [ ] **Step 3: 创建 app/wechat/client.py** ```python import time import httpx from typing import Optional from app.config import settings class WeChatClient: def __init__(self): self._access_token: Optional[str] = None self._token_expires_at: float = 0 async def _get_access_token(self) -> Optional[str]: now = time.time() if self._access_token and now < self._token_expires_at: return self._access_token url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" params = { "corpid": settings.wechat_corp_id, "corpsecret": settings.wechat_secret, } async with httpx.AsyncClient(timeout=30) as client: response = await client.get(url, params=params) data = response.json() if data.get("errcode") == 0: self._access_token = data["access_token"] self._token_expires_at = now + data.get("expires_in", 7200) - 300 return self._access_token return None async def send_text(self, content: str, to_user: str = "@all") -> bool: return await self._send_message("text", {"content": content}, to_user) async def send_markdown(self, content: str, to_user: str = "@all") -> bool: return await self._send_message("markdown", {"content": content}, to_user) async def send_textcard(self, title: str, description: str, url: str, to_user: str = "@all", btn_txt: str = "查看详情") -> bool: return await self._send_message("textcard", { "title": title, "description": description, "url": url, "btntxt": btn_txt, }, to_user) async def _send_message(self, msgtype: str, msg_data: dict, to_user: str = "@all") -> bool: token = await self._get_access_token() if not token: return False url = "https://qyapi.weixin.qq.com/cgi-bin/message/send" params = {"access_token": token} body = { "touser": to_user, "msgtype": msgtype, "agentid": int(settings.wechat_agent_id), msgtype: msg_data, } async with httpx.AsyncClient(timeout=30) as client: response = await client.post(url, params=params, json=body) data = response.json() return data.get("errcode") == 0 ``` - [ ] **Step 4: 创建 app/wechat/handler.py** ```python import xml.etree.cElementTree as ET from typing import Optional from app.wechat.crypto import WXBizMsgCrypt from app.config import settings class WeChatMessageHandler: def __init__(self): self.wxcpt = WXBizMsgCrypt( sToken=settings.wechat_token, sEncodingAESKey=settings.wechat_encoding_aes_key, sReceiveId=settings.wechat_corp_id, ) def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> Optional[str]: ret, sEchoStr = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret == 0: return sEchoStr.decode("utf-8") if isinstance(sEchoStr, bytes) else sEchoStr return None def decrypt_message(self, post_data: str, msg_signature: str, timestamp: str, nonce: str) -> Optional[ET.Element]: ret, xml_content = self.wxcpt.DecryptMsg( post_data, msg_signature, timestamp, nonce ) if ret != 0: return None return ET.fromstring(xml_content) def encrypt_response(self, response_xml: str, nonce: str, timestamp: str) -> Optional[str]: ret, encrypted = self.wxcpt.EncryptMsg(response_xml, nonce, timestamp) if ret == 0: return encrypted return None def handle_event(self, event: str, event_key: Optional[str], from_user: str) -> Optional[str]: return None def handle_text(self, content: str, from_user: str) -> Optional[str]: return None ``` - [ ] **Step 5: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/wechat/ git commit -m "feat: 迁移企业微信模块(crypto + client + handler)" ``` --- ### Task 10: Notification Service **Files:** - Create: `app/services/notification_service.py` - Test: `tests/test_services/test_notification_service.py` - [ ] **Step 1: 创建测试 tests/test_services/test_notification_service.py** ```python import pytest from unittest.mock import AsyncMock, patch from app.services.notification_service import NotificationService @pytest.mark.asyncio async def test_notification_send(): svc = NotificationService() announcements = [ { "title": "测试公告", "publish_date": None, "purchase_name": "测试单位", "content_url": "https://x.com/1", "source_code": "test", "source_name": "测试来源", "announcement_type": "purchase", } ] with patch.object(svc.client, "send_textcard", AsyncMock(return_value=True)): count = await svc.send(announcements) assert count == 1 @pytest.mark.asyncio async def test_notification_send_empty(): svc = NotificationService() count = await svc.send([]) assert count == 0 @pytest.mark.asyncio async def test_notification_send_disabled(): svc = NotificationService() announcements = [{"title": "test"}] with patch.object(svc.client, "send_textcard", AsyncMock(return_value=False)): count = await svc.send(announcements) assert count == 0 ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_services/test_notification_service.py -v # Expected: FAIL ``` - [ ] **Step 3: 创建 app/services/notification_service.py** ```python from datetime import datetime from typing import Any, Dict, List from app.config import settings from app.wechat.client import WeChatClient class NotificationService: def __init__(self): self.client = WeChatClient() async def send(self, announcements: List[Dict[str, Any]]) -> int: if not settings.wechat_enabled: return 0 if not announcements: return 0 sent = 0 for ann in announcements: try: title = ann.get("title", "") if len(title) > 128: title = title[:125] + "..." purchase_name = ann.get("purchase_name", "") if len(purchase_name) > 25: purchase_name = purchase_name[:22] + "..." pub_date = ann.get("publish_date") time_str = pub_date.strftime("%Y-%m-%d %H:%M") if pub_date else "时间未知" source_name = ann.get("source_name", "") description = ( f'
' f'{source_name} | {purchase_name} | {time_str}' f'
' ) url = ann.get("content_url", "") if await self.client.send_textcard(title, description, url): sent += 1 except Exception: continue return sent ``` - [ ] **Step 4: 运行测试验证通过** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_services/test_notification_service.py -v # Expected: 3 passed ``` - [ ] **Step 5: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/services/notification_service.py tests/test_services/test_notification_service.py git commit -m "feat: 添加 NotificationService 通知服务 + 测试" ``` --- ### Task 11: API 路由 **Files:** - Create: `app/api/__init__.py` - Create: `app/api/deps.py` - Create: `app/api/router.py` - Create: `app/api/announcements.py` - Create: `app/api/crawl.py` - Create: `app/api/wechat.py` - Modify: `app/main.py` — 注册路由 - Test: `tests/test_api/__init__.py` - Test: `tests/test_api/test_health.py` - Test: `tests/test_api/test_announcements.py` - Test: `tests/test_api/test_crawl.py` - [ ] **Step 1: 创建 app/api/__init__.py** (空文件) - [ ] **Step 2: 创建 app/api/deps.py** ```python from sqlalchemy.ext.asyncio import AsyncSession from app.main import async_session from app.services.crawl_service import CrawlService from app.crawler.gxgp_spider import GXGPSpider from app.crawler.dahuagov_spider import DahuagovSpider async def get_db() -> AsyncSession: async with async_session() as session: yield session _crawl_service: CrawlService | None = None def get_crawl_service() -> CrawlService: global _crawl_service if _crawl_service is None: _crawl_service = CrawlService() _crawl_service.register(GXGPSpider()) _crawl_service.register(DahuagovSpider()) return _crawl_service ``` - [ ] **Step 3: 创建 app/api/router.py** ```python from fastapi import APIRouter from app.api import announcements, crawl, wechat api_router = APIRouter(prefix="/api/v1") api_router.include_router(announcements.router, tags=["announcements"]) api_router.include_router(crawl.router, tags=["crawl"]) api_router.include_router(wechat.router, tags=["wechat"]) ``` - [ ] **Step 4: 创建 app/api/announcements.py** ```python from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, text from app.api.deps import get_db from app.models.announcement import Announcement from app.models.schemas import AnnouncementResponse, AnnouncementListResponse router = APIRouter() @router.get("/announcements", response_model=AnnouncementListResponse) async def list_announcements( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), source_code: Optional[str] = None, keyword: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, crawl_mode: Optional[str] = None, db: AsyncSession = Depends(get_db), ): conditions = [] if source_code: conditions.append(Announcement.source_code == source_code) if crawl_mode: conditions.append(Announcement.crawl_mode == crawl_mode) if start_date: conditions.append(Announcement.publish_date >= start_date) if end_date: conditions.append(Announcement.publish_date <= end_date) if keyword: conditions.append( Announcement.title.ilike(f"%{keyword}%") ) base_query = select(Announcement) if conditions: base_query = base_query.where(*conditions) count_query = select(func.count()).select_from(base_query.subquery()) total_result = await db.execute(count_query) total = total_result.scalar() or 0 items_query = base_query.order_by(Announcement.publish_date.desc()) \ .offset((page - 1) * page_size).limit(page_size) items_result = await db.execute(items_query) items = items_result.scalars().all() return AnnouncementListResponse( total=total, page=page, page_size=page_size, items=[AnnouncementResponse.model_validate(item) for item in items], ) @router.get("/announcements/{announcement_id}", response_model=AnnouncementResponse) async def get_announcement(announcement_id: int, db: AsyncSession = Depends(get_db)): result = await db.execute( select(Announcement).where(Announcement.id == announcement_id) ) item = result.scalar_one_or_none() if item is None: raise HTTPException(status_code=404, detail="公告不存在") return AnnouncementResponse.model_validate(item) @router.get("/announcements/today", response_model=AnnouncementListResponse) async def get_today_announcements(db: AsyncSession = Depends(get_db)): today = datetime.now().date() result = await db.execute( select(Announcement).where( func.date(Announcement.publish_date) == today ).order_by(Announcement.publish_date.desc()) ) items = result.scalars().all() return AnnouncementListResponse( total=len(items), page=1, page_size=len(items), items=[AnnouncementResponse.model_validate(item) for item in items], ) @router.get("/announcements/stats") async def get_stats(db: AsyncSession = Depends(get_db)): total = await db.execute(select(func.count()).select_from(Announcement)) today_count = await db.execute( select(func.count()).where( func.date(Announcement.publish_date) == func.current_date() ).select_from(Announcement) ) new_count = await db.execute( select(func.count()).where(Announcement.is_new == True) .select_from(Announcement) ) unsent = await db.execute( select(func.count()).where( Announcement.is_sent == False, Announcement.is_new == True ).select_from(Announcement) ) return { "total": total.scalar() or 0, "today": today_count.scalar() or 0, "new": new_count.scalar() or 0, "unsent": unsent.scalar() or 0, } ``` - [ ] **Step 5: 创建 app/api/crawl.py** ```python from fastapi import APIRouter, Depends from app.api.deps import get_crawl_service from app.models.schemas import CrawlTriggerRequest router = APIRouter() @router.post("/crawl/trigger") async def trigger_crawl(request: CrawlTriggerRequest): from app.services.pipeline import PostCrawlPipeline from app.services.notification_service import NotificationService from app.api.deps import get_db import asyncio service = get_crawl_service() names = service.get_spider_names() all_results = [] for name in names: results = await service.run_spider(name) all_results.extend(results) return { "spiders_run": names, "total_announcements": sum(r.total_count for r in all_results), "errors": [r.error_message for r in all_results if not r.success], } @router.get("/crawl/status") async def crawl_status(): service = get_crawl_service() return { "spiders": service.get_spider_names(), "running": False, } @router.get("/crawl/sources") async def crawl_sources(): import json from app.config import settings sources = json.loads(settings.announcement_sources) return { "sources": [ {"code": code, "name": info["name"], "type": info["type"]} for code, info in sources.items() ] } ``` - [ ] **Step 6: 创建 app/api/wechat.py** ```python from fastapi import APIRouter, Request, Response from app.wechat.handler import WeChatMessageHandler router = APIRouter() _handler: WeChatMessageHandler | None = None def get_handler() -> WeChatMessageHandler: global _handler if _handler is None: _handler = WeChatMessageHandler() return _handler @router.get("/wechat/callback") async def wechat_verify(request: Request): handler = get_handler() params = request.query_params echostr = handler.verify_url( params.get("msg_signature", ""), params.get("timestamp", ""), params.get("nonce", ""), params.get("echostr", ""), ) if echostr: return Response(content=echostr, media_type="text/plain") return Response(content="verification failed", status_code=403) @router.post("/wechat/callback") async def wechat_callback(request: Request): handler = get_handler() params = request.query_params post_data = await request.body() post_text = post_data.decode("utf-8") xml_tree = handler.decrypt_message( post_text, params.get("msg_signature", ""), params.get("timestamp", ""), params.get("nonce", ""), ) if xml_tree is None: return Response(content="decrypt failed", status_code=403) msg_type = xml_tree.find("MsgType") msg_type = msg_type.text if msg_type is not None else "unknown" if msg_type == "event": event = xml_tree.find("Event") event_key = xml_tree.find("EventKey") from_user = xml_tree.find("FromUserName") handler.handle_event( event.text if event is not None else "", event_key.text if event_key is not None else None, from_user.text if from_user is not None else "", ) elif msg_type == "text": content = xml_tree.find("Content") from_user = xml_tree.find("FromUserName") handler.handle_text( content.text if content is not None else "", from_user.text if from_user is not None else "", ) return Response(content="success") ``` - [ ] **Step 7: 创建 app/api/scheduler.py** (scheduler endpoints 在 spec 4.3) ```python from fastapi import APIRouter from app.scheduler.jobs import scheduler from app.models.schemas import JobResponse router = APIRouter(prefix="/scheduler", tags=["scheduler"]) @router.get("/jobs") async def list_jobs(): jobs = [] for job in scheduler.get_jobs(): jobs.append(JobResponse( id=job.id, name=job.name, next_run_time=str(job.next_run_time) if job.next_run_time else None, )) return {"jobs": jobs} @router.post("/pause/{job_id}") async def pause_job(job_id: str): scheduler.pause_job(job_id) return {"status": "paused", "job_id": job_id} @router.post("/resume/{job_id}") async def resume_job(job_id: str): scheduler.resume_job(job_id) return {"status": "resumed", "job_id": job_id} ``` - [ ] **Step 8: 修改 app/api/router.py 注册 scheduler 路由** 在 `app/api/router.py` 中添加: ```python from app.api import scheduler as scheduler_module api_router.include_router(scheduler_module.router) ``` - [ ] **Step 9: 修改 app/main.py 注册路由** 在 app/main.py 的 `app = FastAPI(...)` 之后添加: ```python from app.api.router import api_router app.include_router(api_router) ``` - [ ] **Step 10: 创建测试 tests/test_api/__init__.py** (空文件) - [ ] **Step 11: 创建测试 tests/test_api/test_health.py** ```python import pytest from httpx import AsyncClient, ASGITransport from app.main import app @pytest.mark.asyncio async def test_health(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} ``` - [ ] **Step 12: 运行 API 测试** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/test_api/test_health.py -v # Expected: 1 passed ``` - [ ] **Step 13: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/api/ app/main.py tests/test_api/ git commit -m "feat: 添加 API 路由(公告/爬取/微信回调/健康检查)+ 测试" ``` --- ### Task 12: APScheduler 定时任务 **Files:** - Create: `app/scheduler/__init__.py` - Create: `app/scheduler/jobs.py` - Modify: `app/main.py` — lifespan 中启动调度器 - [ ] **Step 1: 创建 app/scheduler/__init__.py** (空文件) - [ ] **Step 2: 创建 app/scheduler/jobs.py** ```python import logging from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.api.deps import get_crawl_service logger = logging.getLogger(__name__) scheduler = AsyncIOScheduler() async def scheduled_crawl(): """定时爬取任务:运行所有 Spider""" logger.info("开始定时爬取任务") service = get_crawl_service() names = service.get_spider_names() for name in names: try: results = await service.run_spider(name) for r in results: if not r.success: logger.error(f"Spider {name} 失败: {r.error_message}") else: logger.info(f"Spider {name} 完成: {r.total_count} 条") except Exception as e: logger.error(f"Spider {name} 异常: {e}") logger.info("定时爬取任务完成") def start_scheduler(): if not settings.scheduler_enabled: return scheduler.add_job( scheduled_crawl, "cron", hour="8,14,18", minute="0", id="scheduled_crawl", name="定时爬取", timezone="Asia/Shanghai", ) scheduler.start() logger.info("APScheduler 已启动 (8:00, 14:00, 18:00)") def shutdown_scheduler(): if scheduler.running: scheduler.shutdown(wait=False) logger.info("APScheduler 已停止") ``` - [ ] **Step 3: 修改 app/main.py 的 lifespan** ```python @asynccontextmanager async def lifespan(app: FastAPI): import logging logging.basicConfig( level=getattr(logging, settings.log_level), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) from app.scheduler.jobs import start_scheduler, shutdown_scheduler start_scheduler() yield shutdown_scheduler() await engine.dispose() ``` - [ ] **Step 4: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add app/scheduler/ app/main.py git commit -m "feat: 添加 APScheduler 定时爬取任务" ``` --- ### Task 13: Docker 化 **Files:** - Create: `docker/Dockerfile` - Create: `docker/docker-compose.yml` - [ ] **Step 1: 创建 docker/Dockerfile** ```dockerfile FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev && \ rm -rf /var/lib/apt/lists/* COPY pyproject.toml . RUN pip install --no-cache-dir -e ".[dev]" COPY . . EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` - [ ] **Step 2: 创建 docker/docker-compose.yml** ```yaml services: app: build: context: .. dockerfile: docker/Dockerfile ports: - "8000:8000" env_file: - ../.env volumes: - ../logs:/app/logs restart: unless-stopped ``` - [ ] **Step 3: 构建验证** ```bash cd /home/v6ole/PythonProject/GX-gp-notify docker build -f docker/Dockerfile -t gx-gp-notify . # Expected: Successfully built ``` - [ ] **Step 4: 提交** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add docker/ git commit -m "feat: 添加 Docker 部署配置" ``` --- ### Task 14: 运行 ruff 代码检查 - [ ] **Step 1: 安装 ruff 并运行** ```bash cd /home/v6ole/PythonProject/GX-gp-notify pip install ruff ruff check app/ tests/ # Fix any issues ruff check --fix app/ tests/ ``` - [ ] **Step 2: 提交修复** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add -A git commit -m "chore: ruff 代码检查与修复" ``` --- ### Task 15: 清理旧文件并最终验证 - [ ] **Step 1: 运行全部测试最终确认** ```bash cd /home/v6ole/PythonProject/GX-gp-notify python -m pytest tests/ -v # Expected: all passed ``` - [ ] **Step 2: 验证服务启动** ```bash cd /home/v6ole/PythonProject/GX-gp-notify uvicorn app.main:app --host 0.0.0.0 --port 8000 & sleep 2 curl http://localhost:8000/health curl http://localhost:8000/docs # 需要 debug=true kill %1 ``` - [ ] **Step 3: 提交最终状态** ```bash cd /home/v6ole/PythonProject/GX-gp-notify git add -A git commit -m "chore: 清理旧文件,最终验证通过" ```