luojunhui před 1 týdnem
rodič
revize
68d18fa6c9

+ 4 - 1
pyproject.toml

@@ -14,6 +14,9 @@ dependencies = [
     "pydantic-settings>=2.0",
     "dependency-injector>=4.0",
     "aliyun-log-python-sdk>=0.9",
+    "tenacity>=8.0",
+    "fake-useragent>=1.4",
+    "requests>=2.31",
 ]
 
 [project.optional-dependencies]
@@ -27,4 +30,4 @@ dev = [
 ]
 
 [tool.setuptools.packages.find]
-include = ["supply*"]
+include = ["src*"]

+ 12 - 0
requirements.txt

@@ -8,3 +8,15 @@ tqdm~=4.66.6
 quart~=0.19.6
 hypercorn
 quart-cors~=0.8.0
+tenacity~=8.0
+fake-useragent~=1.4
+requests~=2.31
+aliyun-log-python-sdk
+aliyun-python-sdk-core
+aliyun-python-sdk-kms
+odps~=3.5.1
+volcengine-python-sdk
+pyapollos
+tenacity
+fake-useragent
+oss2~=2.19.1

+ 1 - 12
src/__init__.py

@@ -22,12 +22,6 @@
 # 配置
 from src.config import (
     GlobalConfig,
-    DatabaseConfig,
-    AliyunLogConfig,
-    AliyunOssConfig,
-    PgConfig,
-    MilvusConfig,
-    RedisConfig,
 )
 
 # 基础设施
@@ -66,12 +60,7 @@ from src.server import create_app
 __all__ = [
     # Config
     "GlobalConfig",
-    "DatabaseConfig",
-    "AliyunLogConfig",
-    "AliyunOssConfig",
-    "PgConfig",
-    "MilvusConfig",
-    "RedisConfig",
+
     # Infra
     "DatabaseManager",
     "create_backend",

+ 1 - 28
src/config/__init__.py

@@ -1,34 +1,7 @@
 """配置层 —— 所有 pydantic-settings 配置模型,环境变量驱动"""
 
-from src.config.database import (
-    DatabaseConfig,
-    AigcDatabaseConfig,
-    LongVideoDatabaseConfig,
-    LongArticlesDatabaseConfig,
-    PiaoquanCrawlerDatabaseConfig,
-    GrowthDatabaseConfig,
-    ContentDeconstructionSupplyConfig,
-    PgConfig,
-    MilvusConfig,
-    RedisConfig,
-)
-from src.config.aliyun import AliyunLogConfig, AliyunOssConfig
-from src.config.xxljob import XxlJobConfig
-from src.config.global_config import GlobalConfig
+from ._global_config import GlobalConfig
 
 __all__ = [
-    "DatabaseConfig",
-    "AigcDatabaseConfig",
-    "LongVideoDatabaseConfig",
-    "LongArticlesDatabaseConfig",
-    "PiaoquanCrawlerDatabaseConfig",
-    "GrowthDatabaseConfig",
-    "ContentDeconstructionSupplyConfig",
-    "PgConfig",
-    "MilvusConfig",
-    "RedisConfig",
-    "AliyunLogConfig",
-    "AliyunOssConfig",
-    "XxlJobConfig",
     "GlobalConfig",
 ]

+ 12 - 3
src/config/global_config.py → src/config/_global_config.py

@@ -3,7 +3,7 @@
 from pydantic import Field
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
-from src.config.database import (
+from .database import (
     AigcDatabaseConfig,
     LongVideoDatabaseConfig,
     LongArticlesDatabaseConfig,
@@ -11,8 +11,10 @@ from src.config.database import (
     GrowthDatabaseConfig,
     ContentDeconstructionSupplyConfig,
 )
-from src.config.aliyun import AliyunLogConfig, AliyunOssConfig
-from src.config.xxljob import XxlJobConfig
+from .aliyun import AliyunLogConfig, AliyunOssConfig
+from .xxljob import XxlJobConfig
+from .apollo import ApolloConfig
+from .volcengine import VolcengineConfig
 
 
 class GlobalConfig(BaseSettings):
@@ -61,6 +63,13 @@ class GlobalConfig(BaseSettings):
     # ============ XXL-JOB 配置 ============
     xxl_job: XxlJobConfig = Field(default_factory=XxlJobConfig)
 
+    # 火山引擎
+    volcengine: VolcengineConfig = Field(default_factory=VolcengineConfig)
+
+    # apollo
+    apollo: ApolloConfig = Field(default_factory=ApolloConfig)
+
+
     model_config = SettingsConfigDict(
         env_file=".env",
         env_file_encoding="utf-8",

+ 28 - 0
src/config/apollo.py

@@ -0,0 +1,28 @@
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from typing import Dict
+
+
+class ApolloConfig(BaseSettings):
+    """Apollo 配置中心配置"""
+
+    app_id: str = Field(default="long-articles-agentic-supply", description="Apollo 应用 ID")
+    env: str = Field(default="prod", description="Apollo 环境: dev/pre/prod")
+
+    # apollo 配置中心地址映射
+    apollo_map: Dict[str, Dict[str, str]] = Field(
+        default_factory=lambda: {
+            "long-articles-agentic-supply": {
+                "pre": "http://preapolloconfig-internal.piaoquantv.com/",
+                "dev": "https://devapolloconfig-internal.piaoquantv.com/",
+                "prod": "https://apolloconfig-internal.piaoquantv.com/",
+            },
+            "longarticle-recommend": {
+                "prod": "https://apolloconfig-internal.piaoquantv.com/"
+            },
+        }
+    )
+
+    model_config = SettingsConfigDict(
+        env_prefix="APOLLO_", env_file=".env", case_sensitive=False, extra="ignore"
+    )

+ 26 - 0
src/config/volcengine.py

@@ -0,0 +1,26 @@
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class VolcengineConfig(BaseSettings):
+    """火山引擎 Ark API 配置"""
+
+    api_key: str = Field(
+        default="ark-0d5dd17a-35a1-48dc-817a-f2f69c743acb-701ce",
+        description="火山引擎 Ark API Key",
+    )
+    base_url: str = Field(
+        default="https://ark.cn-beijing.volces.com/api/v3",
+        description="火山引擎 Ark API 地址",
+    )
+    deepseek_v4_pro: str = Field(
+        default="ep-20260625202225-w4vkd", description="DeepSeek V4-Pro (火山引擎)"
+    )
+
+    model_config = SettingsConfigDict(
+        env_prefix="VOLCENGINE_",
+        env_file=".env",
+        case_sensitive=False,
+        extra="ignore",
+    )
+

+ 2 - 47
src/core/__init__.py

@@ -2,35 +2,9 @@
 
 Agent/Tool/Memory/Orchestrator 契约定义在此层。
 业务 Agent 实现放在独立的业务模块中,通过继承这些基类接入。
-"""
-
-# 配置
-from src.config import (
-    DatabaseConfig,
-    AliyunLogConfig,
-    AliyunOssConfig,
-    AigcDatabaseConfig,
-    LongVideoDatabaseConfig,
-    LongArticlesDatabaseConfig,
-    PiaoquanCrawlerDatabaseConfig,
-    GrowthDatabaseConfig,
-    ContentDeconstructionSupplyConfig,
-    PgConfig,
-    MilvusConfig,
-    RedisConfig,
-)
 
-# 数据库
-from src.infra.database import DatabaseManager, create_backend
-
-# 可观测性
-from src.infra.observability import (
-    LogService,
-    LogRecord,
-    LogLevel,
-    LogCategory,
-    LogStatus,
-)
+此层保持纯抽象,不依赖 infra 或 config 层的具体实现。
+"""
 
 # 生命周期
 from src.core.bootstrap import AppContext
@@ -54,25 +28,6 @@ from src.core.agent import (
 )
 
 __all__ = [
-    "DatabaseConfig",
-    "AliyunLogConfig",
-    "AliyunOssConfig",
-    "AigcDatabaseConfig",
-    "LongVideoDatabaseConfig",
-    "LongArticlesDatabaseConfig",
-    "PiaoquanCrawlerDatabaseConfig",
-    "GrowthDatabaseConfig",
-    "ContentDeconstructionSupplyConfig",
-    "PgConfig",
-    "MilvusConfig",
-    "RedisConfig",
-    "DatabaseManager",
-    "create_backend",
-    "LogService",
-    "LogRecord",
-    "LogLevel",
-    "LogCategory",
-    "LogStatus",
     "AppContext",
     # Agent 框架
     "Agent",

+ 2 - 2
src/core/agent/base.py

@@ -11,8 +11,8 @@ from dataclasses import dataclass, field
 from enum import Enum
 from typing import Any, Dict, List, Optional
 
-from src.core.agent.tool import Tool
-from src.core.agent.memory import Memory
+from .tool import Tool
+from .memory import Memory
 
 
 # ==================== Agent 状态 ====================

+ 1 - 1
src/core/agent/orchestrator.py

@@ -12,7 +12,7 @@ import logging
 from abc import ABC, abstractmethod
 from typing import Any, Dict, List, Optional
 
-from src.core.agent.base import Agent, AgentContext, AgentResult, AgentStatus
+from .base import Agent, AgentContext, AgentResult, AgentStatus
 
 logger = logging.getLogger(__name__)
 

+ 1 - 1
src/handlers/test_hello_world.py

@@ -2,7 +2,7 @@
 
 import logging
 
-from src.infra.xxl_jobs.xxl_decorator import xxl_job
+from src.infra.xxl_jobs import xxl_job
 
 logger = logging.getLogger(__name__)
 

+ 25 - 0
src/infra/__init__.py

@@ -31,6 +31,7 @@ from src.infra.shared import (
     yield_batch,
     timestamp_to_str,
     days_remaining_in_month,
+    request_retry,
 )
 from src.infra.xxl_jobs import (
     XxlJobConfig,
@@ -38,6 +39,19 @@ from src.infra.xxl_jobs import (
     xxl_registry,
     XxlJobExecutor,
 )
+from src.infra.external import (
+    AsyncApolloApi,
+    FeishuBotApi,
+    FeishuSheetApi,
+    OdpsService,
+    fetch_deepseek_completion,
+)
+from src.infra.spider import (
+    get_article_detail,
+    get_article_list_from_account,
+    get_source_account_from_article,
+    weixin_search,
+)
 
 __all__ = [
     # 数据库
@@ -74,4 +88,15 @@ __all__ = [
     "xxl_job",
     "xxl_registry",
     "XxlJobExecutor",
+    # 外部服务
+    "AsyncApolloApi",
+    "FeishuBotApi",
+    "FeishuSheetApi",
+    "OdpsService",
+    "fetch_deepseek_completion",
+    # 爬虫
+    "get_article_detail",
+    "get_article_list_from_account",
+    "get_source_account_from_article",
+    "weixin_search",
 ]

+ 5 - 5
src/infra/database/manager.py

@@ -9,15 +9,15 @@
 import logging
 from typing import Any, Dict, List, Optional
 
-from src.infra.database.ports import (
+from .ports import (
     BackendType,
     BaseBackend,
     SqlBackend,
 )
-from src.infra.database.mysql import MySQLBackend
-from src.infra.database.postgresql import PgBackend
-from src.infra.database.milvus import MilvusBackend
-from src.infra.database.redis import RedisBackend
+from .mysql import MySQLBackend
+from .postgresql import PgBackend
+from .milvus import MilvusBackend
+from .redis import RedisBackend
 
 logger = logging.getLogger(__name__)
 

+ 1 - 1
src/infra/database/milvus.py

@@ -6,7 +6,7 @@
 import logging
 from typing import Any, Optional
 
-from src.infra.database.ports import VectorBackend
+from .ports import VectorBackend
 
 logger = logging.getLogger(__name__)
 

+ 1 - 1
src/infra/database/mysql.py

@@ -5,7 +5,7 @@ from typing import Any, Optional
 
 from aiomysql import create_pool, DictCursor
 
-from src.infra.database.ports import SqlBackend
+from .ports import SqlBackend
 
 logger = logging.getLogger(__name__)
 

+ 1 - 1
src/infra/database/postgresql.py

@@ -6,7 +6,7 @@
 import logging
 from typing import Any, Optional
 
-from src.infra.database.ports import SqlBackend
+from .ports import SqlBackend
 
 logger = logging.getLogger(__name__)
 

+ 1 - 1
src/infra/database/redis.py

@@ -6,7 +6,7 @@
 import logging
 from typing import Any, Optional
 
-from src.infra.database.ports import KvBackend
+from .ports import KvBackend
 
 logger = logging.getLogger(__name__)
 

+ 160 - 0
src/infra/external/apollo.py

@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+import json
+import logging
+import socket
+import asyncio
+import aiohttp
+
+from typing import Dict, Union
+
+from src.config.apollo import ApolloConfig
+
+
+class AsyncApolloClient:
+    def __init__(
+        self,
+        app_id,
+        config_server_url="http://localhost:8080",
+        cluster="default",
+        timeout=35,
+        ip=None,
+    ):
+        self.config_server_url = config_server_url
+        self.appId = app_id
+        self.cluster = cluster
+        self.timeout = timeout
+        self.stopped = False
+        self.ip = ip or self._init_ip()
+
+        self._cache = {}
+        self._notification_map = {"application": -1}
+        self._stop_event = asyncio.Event()
+
+    @staticmethod
+    def _init_ip():
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        try:
+            s.connect(("8.8.8.8", 53))
+            return s.getsockname()[0]
+        finally:
+            s.close()
+
+    async def get_value(
+        self,
+        key,
+        default_val=None,
+        namespace="application",
+        auto_fetch_on_cache_miss=False,
+    ):
+        if namespace not in self._notification_map:
+            self._notification_map[namespace] = -1
+            logging.info(f"Add namespace '{namespace}' to local notification map")
+
+        if namespace not in self._cache:
+            self._cache[namespace] = {}
+            logging.info(f"Add namespace '{namespace}' to local cache")
+            await self._long_poll()
+
+        if key in self._cache[namespace]:
+            return self._cache[namespace][key]
+        elif auto_fetch_on_cache_miss:
+            return await self._cached_http_get(key, default_val, namespace)
+        else:
+            return default_val
+
+    async def start(self):
+        if len(self._cache) == 0:
+            await self._long_poll()
+        asyncio.create_task(self._listener())
+
+    async def stop(self):
+        logging.info("Stopping listener...")
+        self._stop_event.set()
+
+    async def _cached_http_get(self, key, default_val, namespace="application"):
+        url = f"{self.config_server_url}/configfiles/json/{self.appId}/{self.cluster}/{namespace}?ip={self.ip}"
+        async with aiohttp.ClientSession() as session:
+            async with session.get(url) as r:
+                if r.status == 200:
+                    data = await r.json()
+                    self._cache[namespace] = data
+                    logging.info(f"Updated local cache for namespace {namespace}")
+                else:
+                    data = self._cache.get(namespace, {})
+
+        return data.get(key, default_val)
+
+    async def _uncached_http_get(self, namespace="application"):
+        url = f"{self.config_server_url}/configs/{self.appId}/{self.cluster}/{namespace}?ip={self.ip}"
+        async with aiohttp.ClientSession() as session:
+            async with session.get(url) as r:
+                if r.status == 200:
+                    data = await r.json()
+                    self._cache[namespace] = data["configurations"]
+                    logging.info(
+                        f"Updated local cache for namespace {namespace} release key {data['releaseKey']}"
+                    )
+
+    async def _long_poll(self):
+        url = f"{self.config_server_url}/notifications/v2"
+        notifications = [
+            {"namespaceName": k, "notificationId": v}
+            for k, v in self._notification_map.items()
+        ]
+        params = {
+            "appId": self.appId,
+            "cluster": self.cluster,
+            "notifications": json.dumps(notifications, ensure_ascii=False),
+        }
+
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.get(url, params=params, timeout=self.timeout) as r:
+                    if r.status == 304:
+                        logging.debug("No change.")
+                        return
+                    if r.status == 200:
+                        data = await r.json()
+                        for entry in data:
+                            ns = entry["namespaceName"]
+                            nid = entry["notificationId"]
+                            logging.info(f"{ns} has changes: notificationId={nid}")
+                            await self._uncached_http_get(ns)
+                            self._notification_map[ns] = nid
+                    else:
+                        logging.warning("Sleep due to unexpected status...")
+                        await asyncio.sleep(self.timeout)
+        except Exception as e:
+            logging.warning(f"Error during long poll: {e}")
+            await asyncio.sleep(self.timeout)
+
+    async def _listener(self):
+        logging.info("Entering listener loop...")
+        while not self._stop_event.is_set():
+            await self._long_poll()
+        logging.info("Listener stopped.")
+        self.stopped = True
+
+
+class AsyncApolloApi(AsyncApolloClient):
+    def __init__(
+        self, apollo_config: ApolloConfig, app_id: str | None, env: str | None
+    ):
+        if not app_id:
+            app_id = apollo_config.app_id
+
+        if not env:
+            env = apollo_config.env
+
+        server_url = apollo_config.apollo_map.get(app_id, {}).get(env)
+        super().__init__(app_id, server_url)
+
+    async def get_config_value(
+        self, key: str, output_type: str = "json"
+    ) -> Union[Dict, str]:
+        match output_type:
+            case "json":
+                response = await self.get_value(key)
+                return json.loads(response)
+            case _:
+                return await self.get_value(key)

+ 343 - 0
src/infra/external/feishu.py

@@ -0,0 +1,343 @@
+import json
+
+from ..shared import AsyncHttpClient
+
+
+class Feishu:
+    # 服务号分组群发监测机器人
+    server_account_publish_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/380fdecf-402e-4426-85b6-7d9dbd2a9f59"
+
+    # 外部服务号投流监测机器人
+    outside_gzh_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/0899d43d-9f65-48ce-a419-f83ac935bf59"
+
+    # 长文 daily 报警机器人
+    long_articles_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/b44333f2-16c0-4cb1-af01-d135f8704410"
+
+    # 测试环境报警机器人
+    long_articles_bot_dev = "https://open.feishu.cn/open-apis/bot/v2/hook/f32c0456-847f-41f3-97db-33fcc1616bcd"
+
+    # 长文任务报警群
+    long_articles_task_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/223b3d72-f2e8-40e0-9b53-6956e0ae7158"
+
+    # cookie 监测机器人
+    cookie_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/51b9c83a-f50d-44dd-939f-bcd10ac6c55a"
+
+    # rank_bot
+    rank_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/f9dae7ba-decf-436b-b438-41994c35af1e"
+
+    # 用户名与手机号映射
+    name_phone_dict = {
+        "卓异": "18624010360",
+        "俊辉": "18801281360",
+        "范军": "15200827642",
+        "林强": "15810236123",
+    }
+
+    def __init__(self):
+        self.token = None
+        self.headers = {"Content-Type": "application/json"}
+        self.mention_all = {
+            "content": "<at id=all></at>\n",
+            "tag": "lark_md",
+        }
+        self.not_mention = {}
+
+    async def fetch_token(self):
+        url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
+        post_data = {
+            "app_id": "cli_a51114cf8bf8d00c",
+            "app_secret": "cNoTAqMpsAm7mPBcpCAXFfvOzCNL27fe",
+        }
+        async with AsyncHttpClient(default_headers=self.headers) as client:
+            response = await client.post(url=url, json=post_data)
+
+        tenant_access_token = response["tenant_access_token"]
+        self.token = tenant_access_token
+
+    async def get_user_open_id(self, username):
+        """
+        根据用户名获取飞书 open_id
+
+        :param username: 用户名
+        :return: open_id
+        """
+        if not self.token:
+            await self.fetch_token()
+
+        mobile = self.name_phone_dict.get(username)
+        if not mobile:
+            return None
+
+        url = "https://open.feishu.cn/open-apis/user/v1/batch_get_id"
+        headers = {
+            "Authorization": f"Bearer {self.token}",
+            "Content-Type": "application/json; charset=utf-8",
+        }
+        params = {"mobiles": [mobile]}
+
+        async with AsyncHttpClient() as client:
+            response = await client.get(url=url, params=params, headers=headers)
+
+        try:
+            open_id = response["data"]["mobile_users"][mobile][0]["open_id"]
+            return open_id
+        except (KeyError, IndexError):
+            return None
+
+
+class FeishuSheetApi(Feishu):
+    async def prepend_value(self, sheet_token, sheet_id, ranges, values):
+        insert_value_url = "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{}/values_prepend".format(
+            sheet_token
+        )
+        headers = {
+            "Authorization": "Bearer " + self.token,
+            "contentType": "application/json; charset=utf-8",
+        }
+        body = {
+            "valueRange": {"range": "{}!{}".format(sheet_id, ranges), "values": values}
+        }
+        async with AsyncHttpClient() as client:
+            response = await client.post(
+                url=insert_value_url, json=body, headers=headers
+            )
+
+        print(response)
+
+    async def insert_value(self, sheet_token, sheet_id, ranges, values):
+        insert_value_url = (
+            "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{}/values".format(
+                sheet_token
+            )
+        )
+        # self.token = 't-g104bpfHNZN45BVJWFSQEM6WD45AAI4FNRWXCZVK'
+        headers = {
+            "Authorization": "Bearer " + self.token,
+            "contentType": "application/json; charset=utf-8",
+        }
+        body = {
+            "valueRange": {"range": "{}!{}".format(sheet_id, ranges), "values": values}
+        }
+        async with AsyncHttpClient() as client:
+            response = await client.put(
+                url=insert_value_url, json=body, headers=headers
+            )
+
+
+class FeishuBotApi(Feishu):
+    async def create_mention_text(self, usernames):
+        """
+        创建 @ 用户的文本
+
+        :param usernames: 用户名列表
+        :return: @ 用户的 markdown 文本
+        """
+        if not usernames:
+            return ""
+
+        mention_parts = []
+        for username in usernames:
+            open_id = await self.get_user_open_id(username)
+            if open_id:
+                mention_parts.append(f"<at id={open_id}></at>")
+
+        return " ".join(mention_parts) + "\n" if mention_parts else ""
+
+    @classmethod
+    def create_feishu_columns_sheet(
+        cls,
+        sheet_type,
+        sheet_name,
+        display_name,
+        width="auto",
+        vertical_align="top",
+        horizontal_align="left",
+        number_format=None,
+    ):
+        match sheet_type:
+            case "plain_text":
+                return {
+                    "name": sheet_name,
+                    "display_name": display_name,
+                    "width": width,
+                    "data_type": "text",
+                    "vertical_align": vertical_align,
+                    "horizontal_align": horizontal_align,
+                }
+
+            case "lark_md":
+                return {
+                    "name": sheet_name,
+                    "display_name": display_name,
+                    "data_type": "lark_md",
+                }
+
+            case "number":
+                return {
+                    "name": sheet_name,
+                    "display_name": display_name,
+                    "data_type": "number",
+                    "format": number_format,
+                    "width": width,
+                }
+
+            case "date":
+                return {
+                    "name": sheet_name,
+                    "display_name": display_name,
+                    "data_type": "date",
+                    "date_format": "YYYY/MM/DD",
+                }
+
+            case "options":
+                return {
+                    "name": sheet_name,
+                    "display_name": display_name,
+                    "data_type": "options",
+                }
+
+            case _:
+                return {
+                    "name": sheet_name,
+                    "display_name": display_name,
+                    "width": width,
+                    "data_type": "text",
+                    "vertical_align": vertical_align,
+                    "horizontal_align": horizontal_align,
+                }
+
+    # 表格形式
+    async def create_feishu_table(
+        self, title, columns, rows, mention, mention_users=None
+    ):
+        """
+        创建飞书表格消息
+
+        :param title: 标题
+        :param columns: 列定义
+        :param rows: 行数据
+        :param mention: 是否 @ 所有人
+        :param mention_users: 要 @ 的具体用户列表,例如 ["mark"]
+        """
+        mention_element = self.not_mention
+        if mention:
+            mention_element = self.mention_all
+        elif mention_users:
+            mention_text = await self.create_mention_text(mention_users)
+            mention_element = {"content": mention_text, "tag": "lark_md"}
+
+        table_base = {
+            "header": {
+                "template": "blue",
+                "title": {"content": title, "tag": "plain_text"},
+            },
+            "elements": [
+                mention_element,
+                {
+                    "tag": "table",
+                    "page_size": len(rows) + 1,
+                    "row_height": "low",
+                    "header_style": {
+                        "text_align": "left",
+                        "text_size": "normal",
+                        "background_style": "grey",
+                        "text_color": "default",
+                        "bold": True,
+                        "lines": 1,
+                    },
+                    "columns": columns,
+                    "rows": rows,
+                },
+            ],
+        }
+        return table_base
+
+    async def create_feishu_bot_obj(self, title, mention, detail, mention_users=None):
+        """
+        create feishu bot object
+
+        :param title: 标题
+        :param mention: 是否 @ 所有人
+        :param detail: 详细内容
+        :param mention_users: 要 @ 的具体用户列表,例如 ["luojunhui"]
+        """
+        mention_element = self.not_mention
+        if mention:
+            mention_element = self.mention_all
+        elif mention_users:
+            mention_text = await self.create_mention_text(mention_users)
+            mention_element = {"content": mention_text, "tag": "lark_md"}
+
+        return {
+            "elements": [
+                {
+                    "tag": "div",
+                    "text": mention_element,
+                },
+                {
+                    "tag": "div",
+                    "text": {
+                        "content": json.dumps(detail, ensure_ascii=False, indent=4),
+                        "tag": "lark_md",
+                    },
+                },
+            ],
+            "header": {"title": {"content": title, "tag": "plain_text"}},
+        }
+
+    # bot
+    async def bot(
+        self,
+        title,
+        detail,
+        mention=True,
+        table=False,
+        env="long_articles_task",
+        mention_users=None,
+    ):
+        """
+        发送飞书机器人消息
+
+        :param title: 标题
+        :param detail: 详细内容
+        :param mention: 是否 @ 所有人
+        :param table: 是否为表格形式
+        :param env: 环境,决定发送到哪个机器人
+        :param mention_users: 要 @ 的具体用户列表,例如 ["luojunhui"]
+        """
+        match env:
+            case "dev":
+                url = self.long_articles_bot_dev
+            case "prod":
+                url = self.long_articles_bot
+            case "outside_gzh_monitor":
+                url = self.outside_gzh_monitor_bot
+            case "server_account_publish_monitor":
+                url = self.server_account_publish_monitor_bot
+            case "long_articles_task":
+                url = self.long_articles_task_bot
+            case "cookie_monitor":
+                url = self.cookie_monitor_bot
+            case "rank_bot":
+                url = self.rank_monitor_bot
+            case _:
+                url = self.long_articles_bot_dev
+
+        headers = {"Content-Type": "application/json"}
+        if table:
+            card = await self.create_feishu_table(
+                title=title,
+                columns=detail["columns"],
+                rows=detail["rows"],
+                mention=mention,
+                mention_users=mention_users,
+            )
+        else:
+            card = await self.create_feishu_bot_obj(
+                title=title, mention=mention, detail=detail, mention_users=mention_users
+            )
+
+        data = {"msg_type": "interactive", "card": card}
+        async with AsyncHttpClient() as client:
+            res = await client.post(url=url, headers=headers, json=data)
+        return res

+ 38 - 0
src/infra/external/odps.py

@@ -0,0 +1,38 @@
+import asyncio
+from odps import ODPS
+
+
+class OdpsService:
+    def __init__(self, access_id, secret_access_key, project, endpoint):
+        self.odps_client = ODPS(access_id, secret_access_key, project, endpoint)
+
+    async def execute_odps_query(self, query: str) -> bool:
+        loop = asyncio.get_running_loop()
+
+        def _execute():
+            instance = self.odps_client.execute_sql(
+                query, hints={"odps.sql.submit.mode": "script"}
+            )
+            instance.wait_for_success()
+
+        try:
+            await loop.run_in_executor(None, _execute)
+            return True
+        except Exception as e:
+            print(f"[ODPS ERROR] {e}")
+            return False
+
+    async def read_from_odps(self, query: str):
+        loop = asyncio.get_running_loop()
+
+        def _read():
+            with self.odps_client.execute_sql(query).open_reader() as reader:
+                if reader:
+                    return [item for item in reader]
+                return []
+
+        try:
+            return await loop.run_in_executor(None, _read)
+        except Exception as e:
+            print(f"[ODPS READ ERROR] {e}")
+            return []

+ 44 - 0
src/infra/external/volcengine.py

@@ -0,0 +1,44 @@
+"""
+@author: luojunhui
+@description: deepseek 火山引擎 Ark API 版本 (Responses API)
+"""
+
+from typing import Any, Dict, List, Optional
+from volcenginesdkarkruntime import Ark
+
+from src.config import GlobalConfig
+from ..shared.tools import safe_json_parse
+
+config = GlobalConfig()
+
+api_key = config.volcengine.api_key
+base_url = config.volcengine.base_url
+deepseek_model = config.volcengine.deepseek_v4_pro
+
+
+def fetch_deepseek_completion(
+    prompt: str,
+    output_type: str = "text",
+) -> Optional[Dict | List]:
+    input_messages = [{"role": "user", "content": prompt}]
+    kwargs: dict[str, Any] = {
+        "model": deepseek_model,
+        "messages": input_messages,
+    }
+
+    client = Ark(base_url=base_url, api_key=api_key)
+
+    try:
+        response = client.chat.completions.create(**kwargs)
+        output_text = response.choices[0].message.content
+
+        if output_type == "text":
+            return output_text
+        elif output_type == "json":
+            return safe_json_parse(output_text)
+        else:
+            raise ValueError(f"Invalid output_type: {output_type}")
+
+    except Exception as e:
+        print(f"[ERROR] fetch_deepseek_completion (volcengine) failed: {e}")
+        return None

+ 2 - 2
src/infra/observability/logging.py

@@ -18,7 +18,7 @@ from typing import Optional
 from aliyun.log import LogClient, LogItem, PutLogsRequest
 
 from src.config.aliyun import AliyunLogConfig
-from src.infra.observability.log_schema import LogRecord
+from .log_schema import LogRecord
 
 logger = logging.getLogger(__name__)
 
@@ -106,7 +106,7 @@ class LogService:
             level_str = str(contents.pop("level", "INFO")).upper()
             category_str = str(contents.pop("category", "system")).lower()
             try:
-                from src.infra.observability.log_schema import LogLevel, LogCategory
+                from .log_schema import LogLevel, LogCategory
 
                 level = (
                     LogLevel(level_str)

+ 1 - 0
src/infra/shared/__init__.py

@@ -10,4 +10,5 @@ from .tools import (
     yield_batch,
     timestamp_to_str,
     days_remaining_in_month,
+    request_retry,
 )

+ 29 - 2
src/infra/shared/tools.py

@@ -6,8 +6,7 @@ import random
 import re
 import string
 from datetime import datetime, timezone, date, timedelta
-from typing import Dict, List, Optional
-
+from typing import Any, Dict, List, Optional
 
 def safe_json_parse(text: str) -> Optional[Dict | List]:
     """多层降级解析 JSON:直接解析 → 提取代码块 → 提取 JSON 对象/数组
@@ -117,3 +116,31 @@ def days_remaining_in_month() -> int:
         next_month = today.replace(month=today.month + 1, day=1)
     last_day_of_month = next_month - timedelta(days=1)
     return (last_day_of_month - today).days
+
+
+def request_retry(
+    retry_times: int = 3,
+    min_retry_delay: int = 2,
+    max_retry_delay: int = 16,
+) -> Dict[str, Any]:
+    """构造 tenacity @retry 装饰器的参数
+
+    使用指数退避策略,适用于 HTTP 请求等网络 I/O 场景。
+    tenacity 在首次调用时惰性导入,避免通用工具模块强制依赖 tenacity。
+
+    Args:
+        retry_times: 最大重试次数
+        min_retry_delay: 最小退避延迟(秒)
+        max_retry_delay: 最大退避延迟(秒)
+
+    Returns:
+        tenacity @retry 装饰器关键字参数 dict
+    """
+    from tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential
+
+    return {
+        "stop": stop_after_attempt(retry_times),
+        "wait": wait_exponential(multiplier=1, min=min_retry_delay, max=max_retry_delay),
+        "retry": retry_if_exception_type(Exception),
+        "reraise": True,
+    }

+ 14 - 0
src/infra/spider/__init__.py

@@ -0,0 +1,14 @@
+"""爬虫组件 —— 多平台内容抓取"""
+from src.infra.spider.wechat import (
+    get_article_detail,
+    get_article_list_from_account,
+    get_source_account_from_article,
+    weixin_search,
+)
+
+__all__ = [
+    "get_article_detail",
+    "get_article_list_from_account",
+    "get_source_account_from_article",
+    "weixin_search",
+]

+ 12 - 0
src/infra/spider/wechat/__init__.py

@@ -0,0 +1,12 @@
+from .gzh import get_article_detail
+from .gzh import get_article_list_from_account
+from .gzh import get_source_account_from_article
+from .gzh import weixin_search
+
+
+__all__ = [
+    "get_article_detail",
+    "get_article_list_from_account",
+    "get_source_account_from_article",
+    "weixin_search",
+]

+ 151 - 0
src/infra/spider/wechat/gzh.py

@@ -0,0 +1,151 @@
+"""微信爬虫功能组件 —— 微信公众号文章抓取、搜索、账号信息提取
+
+通过 crawler-cn.aiddit.com 代理 API 访问微信生态数据:
+  - 文章详情抓取
+  - 账号下文章列表
+  - 文章页逆向提取账号信息
+  - 关键词搜索
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+
+import requests
+from fake_useragent import FakeUserAgent
+from tenacity import retry
+
+from ...shared.http_client import AsyncHttpClient
+from ...shared.tools import request_retry
+
+logger = logging.getLogger(__name__)
+
+retry_desc = request_retry(retry_times=3, min_retry_delay=2, max_retry_delay=16)
+
+# 微信爬虫代理 API 地址
+BASE_URL = "http://crawler-cn.aiddit.com/crawler/wei_xin"
+HEADERS = {"Content-Type": "application/json"}
+
+
+@retry(**retry_desc)
+async def get_article_detail(
+    article_link: str, is_count: bool = False, is_cache: bool = True
+) -> dict | None:
+    """获取公众号文章详情
+
+    Args:
+        article_link: 文章链接
+        is_count: 是否仅返回计数
+        is_cache: 是否优先使用缓存
+    """
+    target_url = f"{BASE_URL}/detail"
+    payload = json.dumps(
+        {
+            "content_link": article_link,
+            "is_count": is_count,
+            "is_ad": False,
+            "is_cache": is_cache,
+        }
+    )
+    try:
+        async with AsyncHttpClient(timeout=10) as http_client:
+            response = await http_client.post(target_url, headers=HEADERS, data=payload)
+    except Exception as e:
+        logger.error(
+            "get_article_detail API请求失败: %s, link=%s", e, article_link
+        )
+        return None
+    return response
+
+
+@retry(**retry_desc)
+async def get_article_list_from_account(
+    account_id: str, index: str | None = None, is_cache: bool = True
+) -> dict | None:
+    """获取公众号文章列表
+
+    Args:
+        account_id: 公众号 ID
+        index: 分页游标
+        is_cache: 是否优先使用缓存
+    """
+    target_url = f"{BASE_URL}/blogger"
+    payload = json.dumps(
+        {
+            "account_id": account_id,
+            "cursor": index,
+            "token": "1fa4c0ad5c66e43ebd525611f3869f53",
+            "is_cache": is_cache,
+        }
+    )
+    try:
+        async with AsyncHttpClient(timeout=120) as http_client:
+            response = await http_client.post(target_url, headers=HEADERS, data=payload)
+    except Exception as e:
+        logger.error(
+            "get_article_list_from_account API请求失败: %s, account_id=%s, index=%s",
+            e, account_id, index,
+        )
+        return None
+    return response
+
+
+@retry(**retry_desc)
+def get_source_account_from_article(article_link: str) -> dict | None:
+    """从公众号文章页逆向提取账号信息(同步,requests)
+
+    通过正则提取页面中的 nickname 和 username。
+    使用随机 User-Agent 避免被反爬。
+
+    Args:
+        article_link: 文章链接
+    """
+    try:
+        response = requests.get(
+            url=article_link,
+            headers={"User-Agent": FakeUserAgent().random},
+            timeout=120,
+        )
+        response.raise_for_status()
+        html_text = response.text
+
+        regex_nickname = r"hit_nickname:\s*'([^']+)'"
+        regex_username = r"hit_username:\s*'([^']+)'"
+        nickname = re.search(regex_nickname, html_text)
+        username = re.search(regex_username, html_text)
+
+        if nickname and username:
+            return {"name": nickname.group(1), "gh_id": username.group(1)}
+        return {}
+    except requests.exceptions.RequestException as e:
+        logger.error(
+            "get_source_account_from_article 请求失败: %s, link=%s", e, article_link
+        )
+    except json.JSONDecodeError as e:
+        logger.error(
+            "get_source_account_from_article 响应解析失败: %s, link=%s", e, article_link
+        )
+    return None
+
+
+@retry(**retry_desc)
+async def weixin_search(keyword: str, page: str = "1") -> dict | None:
+    """微信关键词搜索
+
+    Args:
+        keyword: 搜索关键词
+        page: 分页游标
+    """
+    url = f"{BASE_URL}/keyword"
+    payload = json.dumps({"keyword": keyword, "cursor": page})
+    try:
+        async with AsyncHttpClient(timeout=120) as http_client:
+            response = await http_client.post(url=url, headers=HEADERS, data=payload)
+    except Exception as e:
+        logger.error(
+            "weixin_search API请求失败: %s, keyword=%s, page=%s", e, keyword, page
+        )
+        return None
+    return response

+ 1 - 1
src/infra/xxl_jobs/xxl_executor.py

@@ -15,7 +15,7 @@ from typing import Any, Dict, Optional
 import aiohttp
 
 from src.config.xxljob import XxlJobConfig
-from src.infra.xxl_jobs.xxl_decorator import xxl_registry
+from .xxl_decorator import xxl_registry
 
 logger = logging.getLogger(__name__)
 

+ 2 - 2
src/server/app.py

@@ -7,8 +7,8 @@ from quart_cors import cors
 
 from src.core.bootstrap import AppContext
 from src.config import GlobalConfig
-from src.infra.database.manager import DatabaseManager, create_backend
-from src.infra.observability.logging import LogService
+from src.infra.database import DatabaseManager, create_backend
+from src.infra.observability import LogService
 from src.infra.xxl_jobs import XxlJobExecutor
 from src.handlers import discover_and_register
 from src.server.xxl_endpoints import set_xxl_executor