diff --git a/app/ai/extract/__init__.py b/app/ai/extract/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/ai/extract/announcement_extract.py b/app/ai/extract/announcement_extract.py
new file mode 100644
index 0000000..a2ca466
--- /dev/null
+++ b/app/ai/extract/announcement_extract.py
@@ -0,0 +1,42 @@
+"""招聘公告信息提取:调用 CONTENT_EXTRACT 模型,输出结构化字典。"""
+
+from __future__ import annotations
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.ai.extract.prompt import EXTRACT_SYSTEM_PROMPT
+from app.ai.model_config import SpiderModel
+from app.core.logger import log
+from app.tool.json_helper import parse_llm_json
+
+
+def extract_announcement(content: str) -> dict | None:
+ """从公告全文中提取结构化信息。
+
+ Args:
+ content: 公告全文(正文 + 图片 OCR 文字 + 二维码内容拼接)。
+
+ Returns:
+ 提取出的字段字典;内容为空、模型调用失败或结果不是字典时返回 None。
+ """
+ if not content or not content.strip():
+ return None
+
+ messages = [
+ SystemMessage(content=EXTRACT_SYSTEM_PROMPT),
+ HumanMessage(content=content),
+ ]
+
+ try:
+ response = SpiderModel.CONTENT_EXTRACT.invoke(messages)
+ except Exception as exc:
+ log.error(f"公告信息提取失败: {exc}")
+ return None
+
+ result = parse_llm_json(str(response.content))
+ if not isinstance(result, dict):
+ log.warning(f"公告信息提取结果不是 JSON 对象: {type(result).__name__}")
+ return None
+
+ log.info(f"公告信息提取完成(字段 {len(result)} 个): {result.get('title')}")
+ return result
diff --git a/app/ai/extract/prompt.py b/app/ai/extract/prompt.py
new file mode 100644
index 0000000..eb876fc
--- /dev/null
+++ b/app/ai/extract/prompt.py
@@ -0,0 +1,79 @@
+"""招聘公告信息提取提示词"""
+
+# 招聘公告信息提取系统提示词:输入公告全文,输出结构化 JSON
+EXTRACT_SYSTEM_PROMPT = """你是招聘公告信息提取专家。用户会给你一份校园招聘公告的全文(由网页正文、图片 OCR 文字、二维码解析内容拼接而成,可能存在顺序错乱、重复、噪声)。
+
+请从中提取信息,严格按下面的 JSON 结构输出。
+
+## 输出格式
+
+```json
+{
+ "company_name": "公司名称",
+ "title": "公告标题",
+ "company_intro": "公司简介",
+ "target_audience": "面向对象",
+ "major_require": "专业要求",
+ "remark": "备注",
+ "written_exam": "有",
+ "apply_start_time": "2026-07-01 00:00:00",
+ "apply_end_time": "2026-09-30 23:59:59",
+ "apply_end_desc": "投递截止描述",
+ "invite_code": "内推码",
+ "apply_url": "投递地址",
+ "apply_email": "投递邮箱",
+ "source": "信息来源说明",
+ "publish_time": "2026-07-01 00:00:00",
+ "recruit_years": [2027],
+ "batches": ["暑期实习"],
+ "tags": ["秋招提前批"],
+ "cities": ["北京市"],
+ "categories": ["IT技术"],
+ "educations": ["本科", "硕士"]
+}
+```
+
+## 字段说明
+
+- company_name:招聘的公司名称,取全称或公告中最完整的写法
+- title:公告标题,如「XX公司2027届秋季校园招聘正式启动」
+- company_intro:公司介绍段落,原文摘录,最多 300 字。公告中没有公司介绍时,可以根据你自己对该公司的了解补充一段简介;不了解该公司时填 null
+- target_audience:面向对象,如「2027届本硕博」「2026届及2027届毕业生」,最多 200 字
+- major_require:专业要求,如「计算机、电子信息、自动化等相关专业」,最多 200 字
+- remark:其他值得注意的补充信息,如薪资待遇、福利、流程安排、注意事项,最多 1000 字
+- written_exam:是否有笔试,只能填「有」「无」「未明确」三者之一
+- apply_start_time:投递开始时间,格式 yyyy-MM-dd HH:mm:ss
+- apply_end_time:投递截止时间,格式 yyyy-MM-dd HH:mm:ss
+- apply_end_desc:无法解析成具体时间的截止描述,如「尽快投递」「招满即止」「长期有效」,最多 64 字
+- invite_code:专属内推码,只填码本身,不要带「内推码:」这类前缀
+- apply_url:投递地址,只填一个,必须是完整 http/https 链接。二维码解出的投递链接也算。
+ 公告中出现多个链接时,根据链接路径判断最可能是网申投递入口的那一个,优先级参考:
+ 路径含 campus / recruit / job / apply / talent / hr / zhaopin / xiaozhao 等招聘语义词 >
+ 企业招聘域名(如 campus.xxx.com、hr.xxx.com、xxx.zhiye.com)>
+ 第三方招聘平台投递页(如 mokahr、beisen、moseeker、workday、talent.liepin 等)>
+ 其他链接。公告首页、公众号文章、官网首页、下载链接、图片链接一律不算投递地址
+- apply_email:投递邮箱,只填一个。出现多个邮箱时,根据邮箱用途判断最可能用于简历投递的那一个,
+ 优先级参考:本地部分含 campus / recruit / hr / job / zhaopin / resume 等招聘语义词 >
+ 企业自有域名邮箱 > 公共邮箱(qq.com / 163.com 等)。咨询、客服、商务合作类邮箱不算投递邮箱
+- source:信息来源说明,如公告中提到的发布方、公众号名称
+- publish_time:公告发布或更新时间,格式 yyyy-MM-dd HH:mm:ss
+- recruit_years:招聘届数数组,整数年份,如 [2027]、[2026, 2027]
+- batches:批次数组,如「实习」「暑期实习」「寒假实习」「秋招专场」「春招提前批」「春招补招」「正式批」
+- cities:工作城市数组,规范到市级,如「北京市」「深圳市」
+- categories:岗位大类数组,如「IT技术」「人工智能」「通信」「芯片硬件」「产品运营」「职能支持」
+- educations:学历要求数组,如「大专」「本科」「硕士」「博士」
+- tags:其他关键标签数组,如「竞争力薪酬」「六险一金」「可远程」「不限专业」
+
+## 提取规则
+
+1. 只提取公告中明确出现的信息,不要推测、不要补全、不要编造(company_intro 例外)。
+2. 字符串字段找不到时填 null,数组字段找不到时填 []。不要填「无」「未知」「暂无」这类占位文字(written_exam 例外)。
+3. 时间统一输出 yyyy-MM-dd HH:mm:ss。
+ - 只有日期没有时刻:开始时间补 00:00:00,截止时间补 23:59:59。
+ - 只写了月日没写年份:结合公告届数或发布时间推断年份;无法推断则填 null。
+ - 出现「即日起」「长期开放」这类无法定位到具体日期的表述:对应时间字段填 null,把原文写进 apply_end_desc。
+4. apply_end_time 和 apply_end_desc 可以同时有值,也可以只有一个。
+5. 数组元素去重,保持公告中出现的顺序,单个元素不超过 64 字。
+6. 一份公告涉及多家公司时,以主体招聘方为准。
+7. 直接输出 JSON,不要输出任何解释文字。
+"""
diff --git a/app/tool/json_helper.py b/app/tool/json_helper.py
new file mode 100644
index 0000000..b56e442
--- /dev/null
+++ b/app/tool/json_helper.py
@@ -0,0 +1,32 @@
+"""AI 输出 JSON 解析工具
+
+将 LLM 返回的可能带 markdown 代码块、思考标签等包裹的文本解析为 Python 对象。
+"""
+
+import re
+
+from json_repair import repair_json
+
+# 匹配 任意内容,用于剥离 DeepSeek R1 等推理模型的思考过程
+# re.DOTALL 让 . 匹配换行,re.IGNORECASE 忽略大小写
+# .*? 非贪婪匹配,避免跨多个 think 标签
+_THINK_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE)
+
+# 匹配 ```json ... ``` 代码块,提取中间的 JSON 内容
+# (?:json\w*)? — 可选的语言标记,兼容 json / JSON / jsonc 等,也兼容无标记的裸 ```
+# \s*\n? — 跳过语言标记后的空白和换行
+# (.*?) — 非贪婪捕获代码块内容(第1组)
+# \n?\s*``` — 匹配结尾的 ```
+_CODE_BLOCK_RE = re.compile(r"```(?:json\w*)?\s*\n?(.*?)\n?\s*```", re.DOTALL | re.IGNORECASE)
+
+
+def parse_llm_json(text: str):
+ """解析 AI 输出的 JSON,自动去除思考标签、markdown 代码块,容错处理"""
+ # 1. 去掉 ... 思考内容
+ cleaned = _THINK_RE.sub("", text).strip()
+ # 2. 如果有 ```json ... ``` 代码块,只取代码块里的内容
+ match = _CODE_BLOCK_RE.search(cleaned)
+ if match:
+ cleaned = match.group(1).strip()
+ # 3. repair_json 容错解析:修复不规范的 JSON(多余逗号、缺引号等)
+ return repair_json(cleaned, return_objects=True)