1. 项目初始化与基础架构 #
本节完成安装 Flask 与数据库相关依赖,通过 .env 管理运行配置,采用应用工厂模式(create_app)组织代码,并建立 SQLAlchemy 数据模型与首个健康检查接口。
整体分层如下:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| 入口 | 启动 Web 服务 | main.py |
| 应用工厂 | 组装 Flask、DB、路由 | app/__init__.py |
| 配置 | 读取环境变量 | app/config.py、.env |
| 扩展 | 数据库单例 | app/extensions.py |
| 模型 | 简历与分析记录 | app/models/ |
| 路由 | HTTP 接口 | app/routes/ |
项目初始化完整时序如下:
1.1 安装依赖 #
uv add flask flask-sqlalchemy pymysql python-dotenv openai DrissionPage tzdata1.2. .env #
.env
# 是否开启Flask调试模式,1表示开启
FLASK_DEBUG=1
# Flask服务器监听的主机地址
FLASK_HOST=127.0.0.1
# Flask服务器监听的端口号
FLASK_PORT=5000
# Flask应用的密钥,用于安全相关(如session)
SECRET_KEY=change-me
# 数据库连接URL,格式为“mysql+pymysql://用户名:密码@主机:端口/数据库名”
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/job31.3. init.py #
app/init.py
# 从Flask框架中导入Flask类
from flask import Flask
# 从app.config模块导入Config配置类
from app.config import Config
# 从app.extensions模块导入db(数据库扩展)
from app.extensions import db
# 从app.routes模块导入register_blueprints函数
from app.routes import register_blueprints
# 定义创建应用工厂函数,可接收一个配置类参数
def create_app(config_class=Config):
# 创建Flask应用实例,指定模板和静态文件目录
app = Flask(__name__,
template_folder=str(config_class.TEMPLATE_DIR),
static_folder=str(config_class.STATIC_DIR))
# 从配置类加载配置信息
app.config.from_object(config_class)
# 初始化数据库,将app绑定到db上
db.init_app(app)
# 注册蓝图到app
register_blueprints(app)
# 在应用上下文中创建所有数据库表
with app.app_context():
db.create_all()
# 返回应用实例
return app1.4. config.py #
app/config.py
# 导入操作系统相关模块
import os
# 导入Path对象,用于处理文件和目录路径
from pathlib import Path
# 导入load_dotenv函数,用于加载.env环境变量文件
from dotenv import load_dotenv
# 获取当前文件的上上级目录作为项目的根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 从根目录下加载.env文件,override=True表示覆盖已存在的环境变量
load_dotenv(BASE_DIR / ".env", override=True)
# 定义配置类
class Config:
# 项目根目录路径
BASE_DIR = BASE_DIR
# 模板文件夹路径
TEMPLATE_DIR = BASE_DIR / "templates"
# 静态文件夹路径
STATIC_DIR = BASE_DIR / "static"
# 从环境变量获取SECRET_KEY, 若无则使用默认值"dev-secret-key"
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
# 从环境变量获取数据库连接地址,若无则用默认的MySQL连接字符串
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL",
"mysql+pymysql://root:password@127.0.0.1:3306/job2",
)
# 是否追踪数据库修改,这里默认关闭
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 从环境变量获取FLASK_DEBUG,字符串为"1"时为True,否则为False
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
# 从环境变量获取Flask服务的主机地址,默认为"127.0.0.1"
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
# 从环境变量获取Flask服务的端口号,默认为5000,并转换为int类型
FLASK_PORT = int(os.getenv("FLASK_PORT", "5000"))1.5. extensions.py #
app/extensions.py
# 从 flask_sqlalchemy 包中导入 SQLAlchemy 类
from flask_sqlalchemy import SQLAlchemy
# 创建 SQLAlchemy 数据库实例对象
db = SQLAlchemy()1.6. init.py #
app/models/init.py
# 从 app.models.resume 模块中导入 Resume 类
from app.models.resume import Resume
# 从 app.models.analysis 模块中导入 Analysis 类
from app.models.analysis import Analysis1.7. analysis.py #
app/models/analysis.py
# 导入用于获取当前时间的datetime模块
from datetime import datetime
# 导入数据库扩展对象db
from app.extensions import db
# 定义Analysis模型类,继承自db.Model
class Analysis(db.Model):
# 指定数据表名称为 "analyses"
__tablename__ = "analyses"
# 定义主键id,类型为整数
id = db.Column(db.Integer, primary_key=True)
# 定义外键resume_id,关联到"resumes"表的id,不能为空
resume_id = db.Column(db.Integer, db.ForeignKey("resumes.id"), nullable=False)
# 定义岗位描述文本字段,类型为Text,不能为空
jd_text = db.Column(db.Text, nullable=False)
# 定义分析结果字段,类型为JSON,可以为空
result = db.Column(db.JSON, nullable=True)
# 定义匹配得分字段,类型为整数,可以为空
score = db.Column(db.Integer, nullable=True)
# 定义是否推荐投递结果,类型为长度16的字符串,可以为空
recommend_apply = db.Column(db.String(16), nullable=True)
# 定义创建时间,类型为DateTime,默认当前时间,不能为空
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# 定义与Resume模型的关系,允许反向查找所有analysis
resume = db.relationship("Resume", back_populates="analyses")1.8. resume.py #
app/models/resume.py
# 导入 datetime 和 timezone 模块用于处理时间
from datetime import datetime, timezone
# 导入数据库扩展对象 db
from app.extensions import db
# 定义简历模型 Resume,继承自 db.Model
class Resume(db.Model):
# 指定数据库表名为 'resumes'
__tablename__ = "resumes"
# 定义主键 id,类型为整数
id = db.Column(db.Integer, primary_key=True)
# 定义文件名字段 filename,类型为字符串,最大长度为256,不能为空
filename = db.Column(db.String(256), nullable=False)
# 定义文本内容字段 text_content,类型为文本,可以为NULL
text_content = db.Column(db.Text, nullable=True)
# 定义创建时间字段 created_at,类型为日期时间,默认值为当前UTC时间,不能为空
created_at = db.Column(
db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False
)
# 定义与 Analysis 模型的关系,反向引用名为 resume
analyses = db.relationship("Analysis", back_populates="resume")1.9. init.py #
app/routes/init.py
# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
# 将health_bp蓝图注册到Flask应用中
app.register_blueprint(health_bp)1.10. health.py #
app/routes/health.py
# 从flask框架中导入Blueprint和jsonify用于创建蓝图和返回json响应
from flask import Blueprint, jsonify
# 从sqlalchemy中导入text用于执行原生SQL语句
from sqlalchemy import text
# 从自定义的app.extensions模块中导入db对象,作为数据库连接
from app.extensions import db
# 创建名为"health"的蓝图实例
health_bp = Blueprint("health", __name__)
# 使用get方法装饰health_check函数,使其响应"/api/health"路由
@health_bp.get("/api/health")
# 定义健康检查的视图函数
def health_check():
# 尝试执行数据库操作,以检测数据库是否正常
try:
# 执行一条测试SQL语句"SELECT 1"以检测数据库连接
db.session.execute(text("SELECT 1"))
# 如果上述代码没有异常,则返回数据库状态正常的json响应和200状态码
return jsonify({"success": True, "database": "ok"}), 200
# 如果执行过程中发生异常
except Exception as exc:
# 返回数据库异常的json响应,包含异常详情,并返回503状态码
return jsonify({"success": False, "database": "error", "detail": str(exc)}), 5031.11. main.py #
main.py
# 从app模块导入create_app函数
from app import create_app
# 从app.config模块导入Config配置类
from app.config import Config
# 调用create_app函数创建一个Flask应用实例
app = create_app()
# 定义主函数
def main():
# 运行Flask应用,主机、端口和调试信息从Config类中获取
app.run(host=Config.FLASK_HOST, port=Config.FLASK_PORT, debug=Config.FLASK_DEBUG)
# 判断当前模块是否为主程序入口
if __name__ == "__main__":
# 如果是,则调用主函数main()
main()2. 简历上传与文件解析 #
本节实现流程第一步「上传简历」:用户通过 /upload 页面上传 PDF 或 DOCX,后端校验并解析为纯文本,写入 Resume 表,并将 resume_id 存入 Session,供后续 JD 输入与分析步骤使用。
涉及模块如下:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| 路由 | 处理上传请求、Session 绑定 | app/routes/resume.py、helpers.py |
| 服务 | 格式校验、PDF/DOCX 文本提取 | app/services/resume_parser.py |
| 模型 | 持久化文件名与解析文本 | app/models/resume.py(第 1 节已建) |
| 模板 | 上传页、步骤条、Flash 提示 | templates/ |
| 静态资源 | 页面样式与交互 | static/css/style.css、static/js/common.js |
简历上传的完整时序如下:
按以下小节顺序逐步创建各文件即可。
2.1 安装依赖 #
uv add pdfplumber python-docx2.2. helpers.py #
app/routes/helpers.py
# 从flask导入session对象,用于会话管理
from flask import session
# 导入数据库db实例
from app.extensions import db
# 导入Resume模型
from app.models import Resume
# 定义获取当前简历的方法
def get_current_resume():
# 从session中获取"resume_id"
resume_id = session.get("resume_id")
# 如果没有"resume_id",返回None
if not resume_id:
return None
# 通过resume_id从数据库中获取Resume对象
resume = db.session.get(Resume, resume_id)
# 如果没有获取到简历对象,则删除session中的"resume_id"
if not resume:
session.pop("resume_id", None)
# 返回获取到的简历对象
return resume
2.3. resume.py #
app/routes/resume.py
# 导入Flask相关模块
from flask import (
# 导入Blueprint用于创建蓝图
Blueprint,
# 导入flash用于闪现消息
flash,
# 导入redirect用于重定向
redirect,
# 导入render_template用于渲染模板
render_template,
# 导入request用于处理请求数据
request,
# 导入session用于会话管理
session,
# 导入url_for用于生成URL
url_for,
)
# 导入数据库对象db
from app.extensions import db
# 导入Resume模型
from app.models import Resume
# 导入简历解析服务parse_resume
from app.services.resume_parser import parse_resume
# 导入获取当前简历的辅助函数get_current_resume
from app.routes.helpers import get_current_resume
# 创建resume蓝图
resume_bp = Blueprint("resume", __name__)
# 配置/upload路由,支持GET和POST方法
@resume_bp.route("/upload", methods=["GET", "POST"])
# 定义upload函数处理简历上传
def upload():
# 如果请求方法为POST
if request.method == "POST":
# 从请求中获取上传的resume文件
file = request.files.get("resume")
# 读取文件内容
data = file.read()
# 调用简历解析服务获取文本内容
text_content = parse_resume(file.filename, data)
# 创建Resume对象并填充文件名和文本内容
resume = Resume(filename=file.filename, text_content=text_content)
# 将Resume对象添加到数据库会话
db.session.add(resume)
# 提交会话保存数据到数据库
db.session.commit()
# 将简历ID保存到session
session["resume_id"] = resume.id
# 闪现简历上传成功的提示信息
flash("简历上传成功", "success")
# 重定向到resume.upload页面
return redirect(url_for("resume.upload"))
# 如果不是POST请求,则渲染上传页面,并传入当前简历和当前步骤数
return render_template("upload.html", resume=get_current_resume(), current_step=1)2.4. resume_parser.py #
app/services/resume_parser.py
# 导入io模块,用于处理字节流
import io
# 从pathlib导入Path,用于处理文件路径
from pathlib import Path
# 导入pdfplumber库,用于解析PDF文件
import pdfplumber
# 从docx库导入Document类,用于解析docx文件
from docx import Document
# 定义允许的文件扩展名集合
ALLOWED_EXTENSIONS = {".pdf", ".docx"}
# 定义文件允许的最大大小,这里是10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
# 定义一个函数,用来校验简历文件的文件名和文件大小
def validate_resume_file(filename: str, file_size: int) -> None:
# 获取文件的扩展名,并转换为小写
ext = Path(filename).suffix.lower()
# 判断扩展名是否在允许列表中
if ext not in ALLOWED_EXTENSIONS:
# 如果不是支持的格式,抛出异常
raise ValueError("仅支持 PDF、DOCX 格式")
# 判断文件大小是否为0
if file_size == 0:
# 如果文件为空,抛出异常
raise ValueError("文件为空,请选择有效文件")
# 判断文件大小是否超过最大限制
if file_size > MAX_FILE_SIZE:
# 如果文件太大,抛出异常
raise ValueError("文件大小不能超过 10 MB")
# 定义一个用于解析PDF文件的函数
def parse_pdf(data: bytes) -> str:
# 创建文本片段列表
text_parts = []
# 使用pdfplumber打开PDF文件
with pdfplumber.open(io.BytesIO(data)) as pdf:
# 遍历PDF的每一页
for page in pdf.pages:
# 提取当前页的文本
page_text = page.extract_text()
# 如果该页有文本,则加入列表
if page_text:
text_parts.append(page_text)
# 将所有页面的文本合并为一个字符串,并去除首尾空白
return "\n".join(text_parts).strip()
# 定义一个用于解析docx文件的函数
def parse_docx(data: bytes) -> str:
# 读取docx文件内容
doc = Document(io.BytesIO(data))
# 提取所有非空段落文本,并组成列表
text_parts = [para.text for para in doc.paragraphs if para.text.strip()]
# 合并所有段落文本为一个字符串,并去除首尾空白
return "\n".join(text_parts).strip()
# 定义一个高层函数,根据文件类型调用对应解析方法
def parse_resume(filename: str, data: bytes) -> str:
# 校验文件名和大小是否符合要求
validate_resume_file(filename, len(data))
# 获取文件扩展名并转成小写
ext = Path(filename).suffix.lower()
# 如果是PDF文件,则调用PDF解析方法
if ext == ".pdf":
text = parse_pdf(data)
# 否则调用docx解析方法
else:
text = parse_docx(data)
# 如果未能提取到文本,抛出异常
if not text:
raise ValueError("未能从文件中提取到文本,请检查文件内容")
# 返回解析得到的文本
return text2.5. style.css #
static/css/style.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
"Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f3f4f6;
}
.container {
width: min(960px, 92%);
margin: 0 auto;
}
.main-content {
padding: 24px 0 48px;
}
.site-header {
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 56px;
}
.logo {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 700;
color: #2563eb;
text-decoration: none;
}
.logo-icon {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #2563eb;
color: #fff;
font-size: 12px;
}
.header-nav a {
color: #6b7280;
text-decoration: none;
}
.header-tagline {
font-size: 13px;
color: #9ca3af;
}
.page-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 32px;
}
.steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 32px;
flex-wrap: wrap;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 72px;
}
.step-num {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
background: #e5e7eb;
color: #6b7280;
}
.step-label {
font-size: 12px;
color: #6b7280;
white-space: nowrap;
}
.step.is-active .step-num {
background: #2563eb;
color: #fff;
}
.step.is-active .step-label {
color: #2563eb;
font-weight: 600;
}
.step.is-done .step-num {
background: #10b981;
color: #fff;
}
.step.is-done .step-label {
color: #10b981;
}
.step-line {
width: 48px;
height: 2px;
background: #e5e7eb;
margin: 0 4px 20px;
}
.step-line.is-done {
background: #10b981;
}
.page-head {
text-align: center;
margin-bottom: 24px;
}
.page-head h1 {
margin: 0 0 8px;
font-size: 1.75rem;
}
.page-head p {
margin: 0;
color: #6b7280;
}
.flash-list {
margin-bottom: 16px;
}
.flash {
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.flash-success {
background: #ecfdf5;
color: #047857;
border: 1px solid #a7f3d0;
}
.flash-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.upload-form {
margin-bottom: 24px;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px;
border: 2px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.upload-zone:hover {
border-color: #2563eb;
background: #eff6ff;
}
.upload-zone.is-dragover {
border-color: #2563eb;
background: #dbeafe;
border-style: solid;
}
.upload-icon {
font-size: 48px;
color: #2563eb;
margin-bottom: 12px;
}
.upload-title {
margin: 0 0 8px;
font-size: 1rem;
font-weight: 600;
}
.upload-hint {
margin: 0 0 16px;
color: #6b7280;
font-size: 14px;
}
.selected-file {
margin: 12px 0 0;
font-size: 14px;
color: #2563eb;
min-height: 20px;
}
.uploaded-file {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f9fafb;
margin-bottom: 16px;
}
.file-icon {
font-size: 28px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 13px;
color: #6b7280;
margin-top: 4px;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badge-success {
background: #ecfdf5;
color: #059669;
}
.btn {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
border: none;
font-size: 15px;
cursor: pointer;
text-align: center;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.btn-outline {
background: #fff;
color: #2563eb;
border: 1px solid #2563eb;
}
.btn-block {
display: block;
width: 100%;
}
.btn-next {
margin-top: 8px;
padding: 14px 20px;
font-size: 16px;
font-weight: 600;
}
.placeholder-note {
padding: 16px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
color: #92400e;
margin-bottom: 16px;
}
.page-card-wide {
width: min(1100px, 96%);
margin: 0 auto;
}
.jd-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid #e5e7eb;
}
.jd-tab {
padding: 10px 16px;
text-decoration: none;
color: #6b7280;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 14px;
}
.jd-tab.is-active {
color: #2563eb;
border-bottom-color: #2563eb;
font-weight: 600;
}
.jd-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 24px;
}
.jd-main {
min-width: 0;
}
.field-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
}
.field-hint {
margin: 8px 0 16px;
font-size: 13px;
color: #6b7280;
}
.jd-textarea {
width: 100%;
min-height: 320px;
padding: 14px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
line-height: 1.6;
resize: vertical;
font-family: inherit;
}
.jd-textarea:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.jd-counter {
text-align: right;
font-size: 13px;
color: #6b7280;
margin: 8px 0 16px;
}
.jd-url-input {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
}
.jd-url-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.parsed-preview {
margin-top: 24px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f9fafb;
}
.parsed-preview-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.parsed-preview-head a {
font-size: 13px;
color: #2563eb;
text-decoration: none;
}
.parsed-preview pre {
margin: 0 0 16px;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
color: #374151;
max-height: 200px;
overflow: auto;
}
.aside-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
font-size: 14px;
}
.aside-card h3 {
margin: 0 0 12px;
font-size: 15px;
}
.aside-card p {
margin: 0 0 10px;
color: #6b7280;
line-height: 1.5;
}
.aside-card ul {
margin: 0;
padding-left: 18px;
color: #6b7280;
}
.aside-card li {
margin-bottom: 6px;
}
.btn-back {
margin-top: 16px;
}
.analyzing-card {
text-align: center;
}
.analyzing-body {
max-width: 520px;
margin: 0 auto;
}
.analyzing-icon {
width: 88px;
height: 88px;
margin: 0 auto 16px;
border-radius: 50%;
background: #eff6ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
border: 3px solid #bfdbfe;
}
.analyzing-body h1 {
margin: 0 0 8px;
font-size: 1.5rem;
}
.analyzing-hint {
color: #6b7280;
margin: 0 0 12px;
}
.analyzing-meta {
font-size: 13px;
color: #9ca3af;
margin: 0 0 24px;
}
.analyzing-status {
color: #2563eb;
font-size: 14px;
margin: 0;
}
.analyzing-steps {
list-style: none;
padding: 0;
margin: 0 0 24px;
text-align: left;
}
.analyzing-steps li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
color: #9ca3af;
}
.analyzing-steps li.is-done {
color: #059669;
}
.analyzing-steps li.is-active {
color: #2563eb;
font-weight: 600;
}
.analyzing-steps li span {
width: 20px;
text-align: center;
}
.analyzing-spin {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.report-top {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
margin-bottom: 28px;
}
.score-card,
.recommend-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
background: #f9fafb;
}
.score-card {
text-align: center;
}
.score-ring {
width: 120px;
height: 120px;
margin: 0 auto 8px;
border-radius: 50%;
background: conic-gradient(#2563eb calc(var(--score) * 1%), #e5e7eb 0);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.score-ring.score-high {
background: conic-gradient(#10b981 calc(var(--score) * 1%), #e5e7eb 0);
}
.score-ring.score-mid {
background: conic-gradient(#f59e0b calc(var(--score) * 1%), #e5e7eb 0);
}
.score-ring.score-low {
background: conic-gradient(#ef4444 calc(var(--score) * 1%), #e5e7eb 0);
}
.score-ring::before {
content: "";
position: absolute;
inset: 10px;
background: #f9fafb;
border-radius: 50%;
}
.score-value {
position: relative;
font-size: 2rem;
font-weight: 700;
color: #2563eb;
}
.score-value.score-high { color: #059669; }
.score-value.score-mid { color: #d97706; }
.score-value.score-low { color: #dc2626; }
.score-value span {
font-size: 0.9rem;
color: #6b7280;
}
.score-label {
margin: 0;
color: #6b7280;
}
.recommend-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 999px;
font-weight: 700;
margin-bottom: 12px;
}
.recommend-apply .recommend-badge {
background: #ecfdf5;
color: #059669;
}
.recommend-watch .recommend-badge {
background: #fffbeb;
color: #d97706;
}
.recommend-skip .recommend-badge {
background: #fef2f2;
color: #dc2626;
}
.recommend-reason {
margin: 0;
line-height: 1.6;
color: #374151;
}
.report-section {
margin-bottom: 28px;
}
.report-section h2 {
margin: 0 0 14px;
font-size: 1.1rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.section-head h2 {
margin: 0;
}
.table-wrap {
overflow-x: auto;
}
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.report-table th,
.report-table td {
border: 1px solid #e5e7eb;
padding: 10px 12px;
text-align: left;
}
.report-table th {
background: #f9fafb;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
}
.tag-green {
background: #ecfdf5;
color: #059669;
}
.tag-red {
background: #fef2f2;
color: #dc2626;
}
.tag-blue {
background: #eff6ff;
color: #2563eb;
}
.tag-orange {
background: #fff7ed;
color: #ea580c;
}
.skill-group {
margin-bottom: 14px;
}
.skill-group h3 {
margin: 0 0 8px;
font-size: 14px;
color: #6b7280;
}
.muted {
color: #9ca3af;
font-size: 14px;
}
.edit-list {
display: grid;
gap: 14px;
}
.edit-item {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
background: #fafafa;
}
.edit-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.edit-label {
margin: 0 0 4px;
font-size: 12px;
color: #6b7280;
}
.edit-text {
margin: 0 0 10px;
line-height: 1.6;
}
.edit-suggested {
color: #1d4ed8;
}
.edit-reason {
margin: 0;
font-size: 13px;
color: #6b7280;
}
.learning-list {
display: grid;
gap: 12px;
}
.learning-item {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
}
.learning-title {
font-weight: 600;
margin-bottom: 6px;
}
.learning-resources {
margin: 6px 0 0;
font-size: 13px;
color: #6b7280;
}
.disclaimer {
padding: 12px 14px;
background: #f3f4f6;
border-radius: 8px;
font-size: 13px;
color: #6b7280;
margin-bottom: 16px;
}
.report-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.report-meta {
font-size: 12px;
color: #9ca3af;
margin: 0;
}
.app-toast {
position: fixed;
left: 50%;
bottom: 32px;
transform: translateX(-50%) translateY(12px);
padding: 10px 18px;
background: #1f2937;
color: #fff;
border-radius: 8px;
font-size: 14px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 1000;
}
.app-toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 768px) {
.page-card {
padding: 20px 16px;
}
.jd-layout {
grid-template-columns: 1fr;
}
.jd-aside {
order: -1;
}
.report-top {
grid-template-columns: 1fr;
}
}
2.6. common.js #
static/js/common.js
// 立即执行函数,形成作用域
(function () {
// 显示提示信息(Toast)的方法
function showToast(message) {
// 获取id为app-toast的元素
let toast = document.getElementById("app-toast");
// 如果还没创建,则新建一个提示元素
if (!toast) {
// 创建div元素
toast = document.createElement("div");
// 设置id为app-toast
toast.id = "app-toast";
// 设置class为app-toast
toast.className = "app-toast";
// 添加到页面body
document.body.appendChild(toast);
}
// 设置提示内容
toast.textContent = message;
// 添加显示的类
toast.classList.add("is-visible");
// 清除上一个定时器
clearTimeout(toast._timer);
// 2秒后隐藏提示信息
toast._timer = setTimeout(() => toast.classList.remove("is-visible"), 2000);
}
// 复制文本到剪贴板的异步方法
async function copyText(text, successMessage) {
// 如果文本为空则返回false
if (!text) return false;
try {
// 尝试写入剪贴板
await navigator.clipboard.writeText(text);
// 显示复制成功的提示
showToast(successMessage);
// 返回true表示成功
return true;
} catch (e) {
// 复制失败显示错误提示
showToast("复制失败,请手动选择文本复制");
// 返回false表示失败
return false;
}
}
// 为所有表单添加提交时的处理
document.querySelectorAll("form").forEach((form) => {
// 绑定表单submit事件
form.addEventListener("submit", () => {
// 查找所有提交按钮
form.querySelectorAll('button[type="submit"], input[type="submit"]').forEach((btn) => {
// 如果按钮已禁用则跳过
if (btn.disabled) return;
// 禁用按钮,防止重复提交
btn.disabled = true;
// 获取加载中的提示文本
const loadingText = btn.dataset.loadingText;
// 如果存在加载文本,切换显示加载文本
if (loadingText) {
// 保存原始文本
btn.dataset.originalText = btn.textContent;
// 显示加载中的文本
btn.textContent = loadingText;
}
});
});
});
// 为所有带copy-edit类的按钮添加复制事件
document.querySelectorAll(".copy-edit").forEach((btn) => {
// 绑定点击事件
btn.addEventListener("click", async () => {
// 查找最近的edit-item内的edit-suggested内容,去除两端空白
const text = btn.closest(".edit-item")?.querySelector(".edit-suggested")?.textContent.trim() || "";
// 复制文本并显示提示
await copyText(text, "已复制");
});
});
// 给“全部复制”按钮绑定点击事件
document.getElementById("copy-all-edits")?.addEventListener("click", async () => {
// 收集所有edit-suggested内容,并去除空白
const texts = [...document.querySelectorAll(".edit-suggested")].map((el) => el.textContent.trim());
// 合并所有内容用两个换行分隔并复制
await copyText(texts.join("\n\n"), "已全部复制");
});
// 将showToast函数挂到window对象,供其他地方使用
window.appShowToast = showToast;
// 将copyText函数挂到window对象,供其他地方使用
window.appCopyText = copyText;
})();
2.7. base.html #
templates/base.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}职策 AI{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a href="{{ url_for('resume.upload') }}" class="logo">
<span class="logo-icon">◎</span> 职策 AI
</a>
<nav class="header-nav">
<span class="header-tagline">简历 × JD 匹配分析</span>
</nav>
</div>
</header>
<main class="container main-content">
{% include "partials/flash.html" %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/common.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
2.8. flash.html #
templates/partials/flash.html
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-list">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
2.9. steps.html #
templates/partials/steps.html
<nav class="steps" aria-label="流程步骤">
<div class="step {% if current_step == 1 %}is-active{% elif current_step > 1 %}is-done{% endif %}">
<span class="step-num">{% if current_step > 1 %}✓{% else %}1{% endif %}</span>
<span class="step-label">上传简历</span>
</div>
<div class="step-line {% if current_step > 1 %}is-done{% endif %}"></div>
<div class="step {% if current_step == 2 %}is-active{% elif current_step > 2 %}is-done{% endif %}">
<span class="step-num">{% if current_step > 2 %}✓{% else %}2{% endif %}</span>
<span class="step-label">输入 JD</span>
</div>
<div class="step-line {% if current_step > 2 %}is-done{% endif %}"></div>
<div class="step {% if current_step == 3 %}is-active{% elif current_step > 3 %}is-done{% endif %}">
<span class="step-num">{% if current_step > 3 %}✓{% else %}3{% endif %}</span>
<span class="step-label">AI 分析</span>
</div>
<div class="step-line {% if current_step > 3 %}is-done{% endif %}"></div>
<div class="step {% if current_step == 4 %}is-active{% elif current_step > 4 %}is-done{% endif %}">
<span class="step-num">{% if current_step > 4 %}✓{% else %}4{% endif %}</span>
<span class="step-label">查看报告</span>
</div>
</nav>
2.10. upload.html #
templates/upload.html
{% extends "base.html" %}
{% block title %}上传简历 — 职策 AI{% endblock %}
{% block content %}
<div class="page-card">
{% include "partials/steps.html" %}
<div class="page-head">
<h1>上传你的简历</h1>
<p>支持 PDF、DOCX 格式,文件大小不超过 10 MB</p>
</div>
<form class="upload-form" method="post" enctype="multipart/form-data" action="{{ url_for('resume.upload') }}">
<label class="upload-zone" id="upload-zone" for="resume-input">
<div class="upload-icon">☁</div>
<p class="upload-title">点击选择文件或拖拽到此处上传</p>
<p class="upload-hint">支持 PDF、DOCX 格式</p>
<span class="btn btn-outline">选择文件</span>
<input id="resume-input" type="file" name="resume" accept=".pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
</label>
<p class="selected-file" id="selected-file-name"></p>
<button type="submit" class="btn btn-primary btn-block" data-loading-text="上传中...">上传简历</button>
</form>
{% if resume %}
<div class="uploaded-file">
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">{{ resume.filename }}</div>
<div class="file-meta">已解析 {{ (resume.text_content or '')|length }} 字</div>
</div>
<span class="badge badge-success">上传成功</span>
</div>
<a href="" class="btn btn-primary btn-block btn-next">
下一步:输入 JD →
</a>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// 获取文件输入框元素
const input = document.getElementById("resume-input");
// 获取拖拽区域元素
const zone = document.getElementById("upload-zone");
// 获取显示已选文件名称的标签
const label = document.getElementById("selected-file-name");
// 设置已选中的文件信息
function setSelectedFile(file) {
// 如果没有文件则直接返回
if (!file) return;
// 获取文件的扩展名并转为小写
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
// 如果文件扩展名不是.pdf或.docx则提示并返回
if (![".pdf", ".docx"].includes(ext)) {
window.appShowToast?.("仅支持 PDF、DOCX 格式");
return;
}
// 显示已选择的文件名
label.textContent = "已选择:" + file.name;
}
// 监听文件选择变化事件
input?.addEventListener("change", () => {
// 如果有文件被选中,则调用setSelectedFile设置
if (input.files?.[0]) setSelectedFile(input.files[0]);
});
// 监听拖拽到拖拽区域的事件
zone?.addEventListener("dragover", (e) => {
// 阻止默认行为(防止浏览器打开文件)
e.preventDefault();
// 给拖拽区域加上高亮样式
zone.classList.add("is-dragover");
});
// 监听拖拽离开拖拽区域的事件
zone?.addEventListener("dragleave", () => {
// 移除高亮样式
zone.classList.remove("is-dragover");
});
// 监听文件被拖拽放下到拖拽区域的事件
zone?.addEventListener("drop", (e) => {
// 阻止默认行为
e.preventDefault();
// 移除拖拽高亮
zone.classList.remove("is-dragover");
// 获取拖拽的文件
const file = e.dataTransfer?.files?.[0];
// 如果没有文件或input为空则返回
if (!file || !input) return;
// 创建DataTransfer对象,用于设置input的files
const dt = new DataTransfer();
// 将文件添加到DataTransfer中
dt.items.add(file);
// 设置input的文件为拖拽选择的文件
input.files = dt.files;
// 设置已选择文件的显示
setSelectedFile(file);
});
</script>
{% endblock %}
2.11. init.py #
app/routes/init.py
# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
+from app.routes.resume import resume_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
# 将health_bp蓝图注册到Flask应用中
app.register_blueprint(health_bp)
+ app.register_blueprint(resume_bp)3. 职位描述(JD)输入与链接解析 #
本节实现流程第二步「输入 JD」:用户在 /jd 页面粘贴职位描述全文,或提交智联等招聘链接,由 DrissionPage 驱动 Chrome 抓取页面 DOM 并提取文本。校验通过后,将 JD 内容写入 Session,为下一步 AI 分析做准备。
涉及模块如下:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| 路由 | 双 Tab 表单、Session 读写、跳转控制 | app/routes/jd.py |
| 服务 | 文本校验、URL 标准化、浏览器抓取 | app/services/jd_parser.py |
| 辅助 | 清除 JD 相关 Session | app/routes/helpers.py |
| 配置 | JD 最大字数上限 | app/config.py(MAX_JD_LENGTH) |
| 模板 | 粘贴文本 / 粘贴链接双模式页面 | templates/jd.html |
JD 输入的完整时序如下:
按以下小节顺序逐步创建各文件即可。
3.1. jd.py #
app/routes/jd.py
# 引入Flask框架相关模块
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
# 引入自定义配置、工具函数和JD解析服务
from app.config import Config
from app.routes.helpers import clear_jd_session, get_current_resume
from app.services.jd_parser import parse_jd_url, validate_jd_text
# 创建一个名为"jd"的蓝图,用于JD相关的路由
jd_bp = Blueprint("jd", __name__)
# 定义/jd路由,支持GET和POST请求
@jd_bp.route("/jd", methods=["GET", "POST"])
def input():
# 获取当前已上传的简历
resume = get_current_resume()
# 如果没有简历,则提示用户先上传简历,并重定向到上传简历页面
if not resume:
flash("请先上传简历", "error")
return redirect(url_for("resume.upload"))
# 处理GET请求逻辑
if request.method == "GET":
# 如果请求参数fresh为"1",则清空JD的会话信息并刷新页面
if request.args.get("fresh") == "1":
clear_jd_session()
return redirect(url_for("jd.input"))
# 获取前端传来的tab参数,默认为"text"
active_tab = request.args.get("tab", "text")
# 判断tab参数是否合法,不合法则默认"text"
if active_tab not in ("text", "link"):
active_tab = "text"
# 从session读取已保存的jd_text和jd_url,若没有则为空字符串
jd_text = session.get("jd_text", "")
jd_url = session.get("jd_url", "")
else:
# 对于非GET请求(即POST),active_tab默认“text”,同时读取session中的jd_text和jd_url
active_tab = "text"
jd_text = session.get("jd_text", "")
jd_url = session.get("jd_url", "")
# 处理POST请求逻辑
if request.method == "POST":
# 获取当前动作类型,默认为“analyze”
action = request.form.get("action", "analyze")
# 如果动作为解析JD链接
if action == "parse_url":
# 获取用户输入的JD链接并去除首尾空格并存入session
jd_url = request.form.get("jd_url", "").strip()
session["jd_url"] = jd_url
try:
# 调用业务方法解析链接内容
jd_text = parse_jd_url(jd_url)
# 解析成功后将内容存入session
session["jd_text"] = jd_text
# 显示成功提示
flash("链接解析成功,已填入 JD 文本", "success")
# 重定向回/jd页面并切换到text标签
return redirect(url_for("jd.input", tab="text"))
except ValueError as exc:
# 解析异常时提示错误,并跳到link标签
flash(str(exc), "error")
return redirect(url_for("jd.input", tab="link"))
try:
# 判断form表单是否传递了jd_text字段
if "jd_text" in request.form:
source_text = request.form.get("jd_text", "")
else:
# 否则从session提取JD文本
source_text = session.get("jd_text", "")
# 校验并规范化JD文本内容
jd_text = validate_jd_text(source_text)
# 校验成功后将JD文本存入session
session["jd_text"] = jd_text
# 返回简单文本表示正在分析(实际生产可能redirect到另一个页面)
#return redirect(url_for("analyze.waiting"))
return "分析中..."
except ValueError as exc:
# 校验失败则提示错误
flash(str(exc), "error")
# 获取表单或者session中已有的JD文本
jd_text = request.form.get("jd_text", session.get("jd_text", ""))
# 如果form提交了jd_text则为text标签,否则为link标签
active_tab = "text" if "jd_text" in request.form else "link"
# 渲染jd.html模板,并传递相关上下文参数
return render_template(
"jd.html",
resume=resume,
current_step=2,
jd_text=jd_text,
jd_url=jd_url,
active_tab=active_tab,
max_jd_length=Config.MAX_JD_LENGTH,
)
3.2. jd_parser.py #
app/services/jd_parser.py
# 导入日志模块
import logging
# 导入操作系统相关模块
import os
# 从DrissionPage库导入ChromiumPage和ChromiumOptions
from DrissionPage import ChromiumPage, ChromiumOptions
# 从app.config模块导入Config配置
from app.config import Config
# 获取logger对象,用于写日志
logger = logging.getLogger(__name__)
# 定义一组DOM选择器,用于查找JD内容
DOM_SELECTORS = (
".describtion__detail-content",
".summary-plane",
".job-detail-content",
"css:div[class*='describtion']",
"css:div[class*='job-detail']",
)
# 判断是否使用无头浏览器
def _browser_headless() -> bool:
# 从环境变量获取JD_BROWSER_HEADLESS,默认为"0",等于"1"则为无头模式
return os.getenv("JD_BROWSER_HEADLESS", "0") == "1"
# 校验并清洗JD文本内容
def validate_jd_text(jd_text: str) -> str:
# 去除首尾空格
text = jd_text.strip()
# 如果长度小于20,抛出异常
if len(text) < 20:
raise ValueError("JD 内容过短,请粘贴完整的职位描述")
# 如果长度超过配置上限,也抛出异常
if len(text) > Config.MAX_JD_LENGTH:
raise ValueError(f"JD 内容不能超过 {Config.MAX_JD_LENGTH} 字")
# 返回合法文本
return text
# 对输入URL进行标准化处理
def normalize_url(url: str) -> str:
# 去除首尾空格
url = url.strip()
# 如果不是http开头,补充https://
if not url.startswith("http"):
url = "https://" + url.lstrip("/")
# 返回标准化后的url
return url
# 创建Chromium浏览器页面对象
def _create_browser_page() -> ChromiumPage:
# 初始化Chromium参数配置对象
options = ChromiumOptions()
# 如果无头模式,设置无头
if _browser_headless():
options.headless()
# 关闭自动化检测功能
options.set_argument("--disable-blink-features=AutomationControlled")
# 关闭首次运行弹窗
options.set_argument("--no-first-run")
# 不询问是否设为默认浏览器
options.set_argument("--no-default-browser-check")
# 从环境变量获取用户数据目录
user_data = os.getenv("JD_BROWSER_USER_DATA", "").strip()
# 获取浏览器Profile
profile = os.getenv("JD_BROWSER_PROFILE", "Default").strip()
# 如果设置了用户数据
if user_data:
# 指定用户数据路径
options.set_user_data_path(user_data)
# 指定Profile目录
options.set_argument(f"--profile-directory={profile}")
# 返回配置好的浏览器页面对象
return ChromiumPage(options)
# 从页面DOM中提取JD文本
def _extract_from_dom(page: ChromiumPage) -> str | None:
# 遍历所有DOM选择器
for selector in DOM_SELECTORS:
try:
# 查找元素,超时时间3秒
element = page.ele(selector, timeout=3)
# 如果没有找到,继续下一个选择器
if not element:
continue
# 获取元素文本内容并去除首尾空白
text = (element.text or "").strip()
# 内容长度大于等于30字认为抓到了有效JD
if len(text) >= 30:
return text
# 捕捉所有异常,忽略并尝试下一个
except Exception:
continue
# 没找到则返回None
return None
# 抓取并返回页面的JD文本
def _fetch_page(url: str) -> tuple[str, str | None]:
# 创建浏览器页面对象
page = _create_browser_page()
try:
# 加载指定url
page.get(url)
# 等待页面文档加载完成,最长20秒
page.wait.doc_loaded(timeout=20)
# 再等待2秒,确保资源加载
page.wait(2)
# 从DOM中提取内容
dom_text = _extract_from_dom(page)
# 返回提取到的文本
return dom_text
# 无论如何都需要关闭浏览器实例
finally:
try:
# 关闭页面
page.quit()
except Exception:
# 关闭失败打印警告日志
logger.warning("关闭浏览器实例失败", exc_info=True)
# 从JD链接解析JD文本
def parse_jd_url(url: str) -> str:
# 对url标准化
normalized = normalize_url(url)
try:
# 通过浏览器拉取并解析内容
dom_text = _fetch_page(normalized)
# 抓取过程出现异常则记录警告日志并抛自定义异常
except Exception as exc:
logger.warning("DrissionPage 抓取失败: %s", exc)
raise ValueError(
"链接访问失败(需本机安装 Chrome),请直接粘贴 JD 文本"
) from exc
# 如果未能提取内容,也抛出异常
if not dom_text:
raise ValueError("链接解析失败,请直接粘贴 JD 文本")
# 返回最终提取的JD文本
return dom_text3.3. jd.html #
templates/jd.html
{% extends "base.html" %}
{% block title %}输入 JD — 职策 AI{% endblock %}
{% block content %}
<div class="page-card page-card-wide">
{% include "partials/steps.html" %}
<div class="page-head">
<h1>输入职位描述(JD)</h1>
<p>支持粘贴文本或招聘链接,当前简历:{{ resume.filename }}</p>
</div>
<div class="jd-tabs">
<a href="{{ url_for('jd.input', tab='text') }}"
class="jd-tab {% if active_tab == 'text' %}is-active{% endif %}">粘贴文本</a>
<a href="{{ url_for('jd.input', tab='link') }}"
class="jd-tab {% if active_tab == 'link' %}is-active{% endif %}">粘贴链接</a>
</div>
<div class="jd-layout">
<div class="jd-main">
{% if active_tab == 'text' %}
<form method="post" action="{{ url_for('jd.input') }}">
<input type="hidden" name="action" value="analyze">
<label class="field-label" for="jd-text">JD 全文</label>
<textarea id="jd-text" name="jd_text" class="jd-textarea" maxlength="{{ max_jd_length }}"
placeholder="请粘贴职位描述(JD)全文...">{{ jd_text }}</textarea>
<div class="jd-counter">
<span id="jd-char-count">{{ jd_text|length }}</span> / {{ max_jd_length }}
</div>
<button type="submit" class="btn btn-primary btn-block btn-next" data-loading-text="准备分析...">🚀 开始分析</button>
</form>
{% else %}
<form method="post" action="{{ url_for('jd.input') }}">
<input type="hidden" name="action" value="parse_url">
<label class="field-label" for="jd-url">智联招聘职位链接</label>
<input id="jd-url" type="url" name="jd_url" class="jd-url-input"
value="{{ jd_url }}"
placeholder="https://www.zhaopin.com/jobdetail/xxx.htm">
<p class="field-hint">示例:https://www.zhaopin.com/jobdetail/CCL1525384510J40948598308.htm</p>
<p class="field-hint">使用 DrissionPage 打开 Chrome 解析,首次可能需 10–20 秒;若触发安全验证请改粘贴文本。</p>
<button type="submit" class="btn btn-primary btn-block" data-loading-text="解析中,请稍候...">解析链接</button>
</form>
{% if jd_text %}
<div class="parsed-preview">
<div class="parsed-preview-head">
<strong>解析结果预览</strong>
<a href="{{ url_for('jd.input', tab='text') }}">编辑文本 →</a>
</div>
<pre>{{ jd_text[:500] }}{% if jd_text|length > 500 %}...{% endif %}</pre>
<form method="post" action="{{ url_for('jd.input') }}">
<input type="hidden" name="action" value="analyze">
<button type="submit" class="btn btn-primary btn-block btn-next" data-loading-text="准备分析...">🚀 开始分析</button>
</form>
</div>
{% endif %}
{% endif %}
<a href="{{ url_for('resume.upload') }}" class="btn btn-outline btn-back">← 返回上传简历</a>
</div>
<aside class="jd-aside">
<div class="aside-card">
<h3>粘贴链接说明</h3>
<p>在招聘网站打开职位详情页,复制浏览器地址栏链接粘贴到左侧。</p>
<p>若解析失败,请切换到「粘贴文本」手动复制 JD 内容。</p>
<ul>
<li>使用 DrissionPage + Chrome 解析</li>
<li>解析约 10–20 秒,可能触发安全验证</li>
</ul>
</div>
</aside>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// 获取 id 为 "jd-text" 的 textarea 元素
const textarea = document.getElementById("jd-text");
// 获取 id 为 "jd-char-count" 的字符计数显示元素
const counter = document.getElementById("jd-char-count");
// 如果 textarea 和 counter 都存在
if (textarea && counter) {
// 定义更新计数的函数,将 textarea 的字符长度赋值给 counter 的文本
const updateCount = () => { counter.textContent = textarea.value.length; };
// 监听 textarea 的输入事件,内容变动时调用 updateCount 更新计数
textarea.addEventListener("input", updateCount);
// 页面加载时立即执行一次计数更新
updateCount();
}
</script>
{% endblock %}
3.4. config.py #
app/config.py
# 导入操作系统相关模块
import os
# 导入Path对象,用于处理文件和目录路径
from pathlib import Path
# 导入load_dotenv函数,用于加载.env环境变量文件
from dotenv import load_dotenv
# 获取当前文件的上上级目录作为项目的根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 从根目录下加载.env文件,override=True表示覆盖已存在的环境变量
load_dotenv(BASE_DIR / ".env", override=True)
# 定义配置类
class Config:
# 项目根目录路径
BASE_DIR = BASE_DIR
# 模板文件夹路径
TEMPLATE_DIR = BASE_DIR / "templates"
# 静态文件夹路径
STATIC_DIR = BASE_DIR / "static"
# 从环境变量获取SECRET_KEY, 若无则使用默认值"dev-secret-key"
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
# 从环境变量获取数据库连接地址,若无则用默认的MySQL连接字符串
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL",
"mysql+pymysql://root:password@127.0.0.1:3306/job2",
)
# 是否追踪数据库修改,这里默认关闭
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 从环境变量获取FLASK_DEBUG,字符串为"1"时为True,否则为False
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
# 从环境变量获取Flask服务的主机地址,默认为"127.0.0.1"
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
# 从环境变量获取Flask服务的端口号,默认为5000,并转换为int类型
FLASK_PORT = int(os.getenv("FLASK_PORT", "5000"))
# 从环境变量获取最大JD长度,默认为10000
+ MAX_JD_LENGTH = int(os.getenv("MAX_JD_LENGTH", "10000"))3.5. init.py #
app/routes/init.py
# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 从app.routes.resume模块导入resume_bp蓝图
from app.routes.resume import resume_bp
# 从app.routes.jd模块导入jd_bp蓝图
+from app.routes.jd import jd_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
# 将health_bp蓝图注册到Flask应用中
app.register_blueprint(health_bp)
# 将resume_bp蓝图注册到Flask应用中
+ app.register_blueprint(resume_bp)
# 将jd_bp蓝图注册到Flask应用中
+ app.register_blueprint(jd_bp)3.6. helpers.py #
app/routes/helpers.py
# 从flask导入session对象,用于会话管理
from flask import session
# 导入数据库db实例
from app.extensions import db
# 导入Resume模型
from app.models import Resume
# 定义获取当前简历的方法
def get_current_resume():
# 从session中获取"resume_id"
resume_id = session.get("resume_id")
# 如果没有"resume_id",返回None
if not resume_id:
return None
# 通过resume_id从数据库中获取Resume对象
resume = db.session.get(Resume, resume_id)
# 如果没有获取到简历对象,则删除session中的"resume_id"
if not resume:
session.pop("resume_id", None)
# 返回获取到的简历对象
return resume
# 定义一个函数用于清除与JD相关的session信息
+def clear_jd_session() -> None:
# 移除session中的"jd_text"项,如果不存在则不报错
+ session.pop("jd_text", None)
# 移除session中的"jd_url"项,如果不存在则不报错
+ session.pop("jd_url", None)3.7. upload.html #
templates/upload.html
{% extends "base.html" %}
{% block title %}上传简历 — 职策 AI{% endblock %}
{% block content %}
<div class="page-card">
{% include "partials/steps.html" %}
<div class="page-head">
<h1>上传你的简历</h1>
<p>支持 PDF、DOCX 格式,文件大小不超过 10 MB</p>
</div>
<form class="upload-form" method="post" enctype="multipart/form-data" action="{{ url_for('resume.upload') }}">
<label class="upload-zone" id="upload-zone" for="resume-input">
<div class="upload-icon">☁</div>
<p class="upload-title">点击选择文件或拖拽到此处上传</p>
<p class="upload-hint">支持 PDF、DOCX 格式</p>
<span class="btn btn-outline">选择文件</span>
<input id="resume-input" type="file" name="resume" accept=".pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
</label>
<p class="selected-file" id="selected-file-name"></p>
<button type="submit" class="btn btn-primary btn-block" data-loading-text="上传中...">上传简历</button>
</form>
{% if resume %}
<div class="uploaded-file">
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">{{ resume.filename }}</div>
<div class="file-meta">已解析 {{ (resume.text_content or '')|length }} 字</div>
</div>
<span class="badge badge-success">上传成功</span>
</div>
+ <a href="{{ url_for('jd.input') }}" class="btn btn-primary btn-block btn-next">
下一步:输入 JD →
</a>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// 获取文件输入框元素
const input = document.getElementById("resume-input");
// 获取拖拽区域元素
const zone = document.getElementById("upload-zone");
// 获取显示已选文件名称的标签
const label = document.getElementById("selected-file-name");
// 设置已选中的文件信息
function setSelectedFile(file) {
// 如果没有文件则直接返回
if (!file) return;
// 获取文件的扩展名并转为小写
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
// 如果文件扩展名不是.pdf或.docx则提示并返回
if (![".pdf", ".docx"].includes(ext)) {
window.appShowToast?.("仅支持 PDF、DOCX 格式");
return;
}
// 显示已选择的文件名
label.textContent = "已选择:" + file.name;
}
// 监听文件选择变化事件
input?.addEventListener("change", () => {
// 如果有文件被选中,则调用setSelectedFile设置
if (input.files?.[0]) setSelectedFile(input.files[0]);
});
// 监听拖拽到拖拽区域的事件
zone?.addEventListener("dragover", (e) => {
// 阻止默认行为(防止浏览器打开文件)
e.preventDefault();
// 给拖拽区域加上高亮样式
zone.classList.add("is-dragover");
});
// 监听拖拽离开拖拽区域的事件
zone?.addEventListener("dragleave", () => {
// 移除高亮样式
zone.classList.remove("is-dragover");
});
// 监听文件被拖拽放下到拖拽区域的事件
zone?.addEventListener("drop", (e) => {
// 阻止默认行为
e.preventDefault();
// 移除拖拽高亮
zone.classList.remove("is-dragover");
// 获取拖拽的文件
const file = e.dataTransfer?.files?.[0];
// 如果没有文件或input为空则返回
if (!file || !input) return;
// 创建DataTransfer对象,用于设置input的files
const dt = new DataTransfer();
// 将文件添加到DataTransfer中
dt.items.add(file);
// 设置input的文件为拖拽选择的文件
input.files = dt.files;
// 设置已选择文件的显示
setSelectedFile(file);
});
</script>
{% endblock %}
4. 匹配分析 #
本节实现流程第三步「AI 分析」:用户在 JD 页点击「开始分析」后跳转至 /analyze,页面自动提交分析请求;后端调用 DeepSeek API(通过 OpenAI SDK),将简历与 JD 做匹配分析,校验 JSON 结果后写入 Analysis 表,为下一步查看报告做准备。
涉及模块如下:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| 路由 | 分析等待页、触发分析、结果入库 | app/routes/analyze.py |
| 服务 | Prompt 构建、API 调用、JSON 校验与重试 | app/services/ai_analyzer.py |
| 模型 | 持久化分析结果 | app/models/analysis.py(第 1 节已建) |
| 配置 | DeepSeek API Key、模型、文本长度上限 | .env、app/config.py |
| 模板 | 分析进度页(自动提交表单) | templates/analyzing.html |
AI 分析的完整时序如下:
按以下小节顺序逐步创建各文件即可。
4.1. analyze.py #
app/routes/analyze.py
# 导入Blueprint、flash、redirect、render_template、session、url_for,用于路由、消息、跳转、渲染模板和会话管理
from flask import Blueprint, flash, redirect, render_template, session, url_for
# 导入数据库扩展db
from app.extensions import db
# 导入Analysis模型类
from app.models import Analysis
# 导入获取当前简历的辅助函数
from app.routes.helpers import get_current_resume
# 导入用于简历与JD分析的AI服务
from app.services.ai_analyzer import analyze_resume_jd
# 创建名为"analyze"的蓝图
analyze_bp = Blueprint("analyze", __name__)
# 定义GET请求路由/analyze
@analyze_bp.get("/analyze")
def waiting():
# 获取当前会话下的简历
resume = get_current_resume()
# 如果简历未上传或不存在,提示并跳转到简历上传页面
if not resume:
flash("请先上传简历", "error")
return redirect(url_for("resume.upload"))
# 获取已输入的JD文本并去除首尾空格
jd_text = session.get("jd_text", "").strip()
# 如果JD文本为空,提示并跳转到JD输入页面
if not jd_text:
flash("请先输入 JD", "error")
return redirect(url_for("jd.input"))
# 渲染分析中的页面
return render_template(
"analyzing.html",
resume=resume,
jd_preview=jd_text[:120] + ("..." if len(jd_text) > 120 else ""),
current_step=3,
)
# 定义POST请求路由/analyze/run
@analyze_bp.post("/analyze/run")
def run():
# 获取当前会话下的简历
resume = get_current_resume()
# 如果简历未上传或不存在,提示并跳转到简历上传页面
if not resume:
flash("请先上传简历", "error")
return redirect(url_for("resume.upload"))
# 获取已输入的JD文本并去除首尾空格
jd_text = session.get("jd_text", "").strip()
# 如果JD文本为空,提示并跳转到JD输入页面
if not jd_text:
flash("请先输入 JD", "error")
return redirect(url_for("jd.input"))
# 若简历文本内容为空,提示并跳转到简历上传页面
if not resume.text_content:
flash("简历文本为空,请重新上传", "error")
return redirect(url_for("resume.upload"))
# 尝试调用AI分析服务获取结果
try:
result = analyze_resume_jd(resume.text_content, jd_text)
# 捕获分析服务抛出的ValueError异常,显示异常消息,并重定向回等待页面
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("analyze.waiting"))
# 捕获其它异常,提示分析失败,并重定向回等待页面
except Exception:
flash("分析失败,请稍后重试", "error")
return redirect(url_for("analyze.waiting"))
# 构建Analysis对象保存本次分析的各项数据
analysis = Analysis(
resume_id=resume.id,
jd_text=jd_text,
result=result,
score=result.get("score"),
recommend_apply=result.get("recommend_apply"),
)
# 把Analysis对象加入数据库会话
db.session.add(analysis)
# 尝试提交数据库操作
try:
db.session.commit()
# 如果提交失败,回滚并提示,重定向回等待页面
except Exception:
db.session.rollback()
flash("分析结果保存失败,请稍后重试", "error")
return redirect(url_for("analyze.waiting"))
# # 跳转到分析结果页面(原注释掉的代码)
# return redirect(url_for("report.show", analysis_id=analysis.id))
# 临时直接返回文本“查看分析结果...”
return "查看分析结果..."
4.2. ai_analyzer.py #
app/services/ai_analyzer.py
# 导入 json 模块用于处理 JSON 数据
import json
# 导入 logging 模块用于日志记录
import logging
# 导入 re 模块用于正则表达式处理
import re
# 导入 OpenAI SDK,用于调用 DeepSeek 兼容接口
from openai import APIConnectionError, APIStatusError, AuthenticationError, OpenAI
# 从 Flask 中导入 current_app 获取当前应用配置
from flask import current_app
# 创建名为 logger 的日志记录器,名称为当前模块名
logger = logging.getLogger(__name__)
# 定义有效的推荐动作集合
VALID_RECOMMEND = {"apply", "watch", "skip"}
# 定义用于文本截断的私有函数
def _truncate(text: str, max_chars: int, label: str) -> str:
# 如果文本长度小于等于最大字符数则返回原文本
if len(text) <= max_chars:
return text
# 记录文本超长被截断的日志
logger.info("%s 过长(%d 字),截断至 %d 字", label, len(text), max_chars)
# 截断文本并添加截断提示
return text[:max_chars] + "\n\n[内容已截断]"
# 构建 system prompt 的函数
def _build_system_prompt() -> str:
# 返回用于 AI 的 system prompt
return (
"你是资深技术招聘专家。请根据简历与 JD 做匹配分析。"
"必须严格输出 JSON,不要输出 JSON 以外的任何内容。"
"score 为 0-100 整数;recommend_apply 只能是 apply、watch、skip 之一;"
"resume_edits 至少 3 条,每条包含 section、original、suggested、reason。"
)
# 构建 user prompt 的函数
def _build_user_prompt(resume_text: str, jd_text: str, *, retry_hint: bool = False) -> str:
# 构造用于给 AI 的 JSON schema 示例
schema = {
"score": 82,
"recommend_apply": "apply",
"recommend_reason": "一句话决策理由",
"hard_requirements": {
"experience": {"required": "3-5年", "match": True, "note": ""},
"education": {"required": "本科", "match": True, "note": ""},
"city": {"required": "北京", "match": True, "note": ""},
"salary": {"required": "25-40K", "match": None, "note": ""},
},
"skills": {
"matched": ["Python"],
"missing": ["Go"],
"bonus": ["Redis"],
},
"resume_edits": [
{
"section": "项目经验",
"original": "原文",
"suggested": "改后",
"reason": "改写理由",
}
],
"learning_path": {
"needed": True,
"items": [
{
"skill": "Go",
"priority": "中",
"estimated_weeks": 4,
"resources": [],
}
],
},
}
# 拼接 prompt,包含 JD 和简历内容及 JSON schema 模板
prompt = (
f"【JD】\n{jd_text}\n\n"
f"【简历】\n{resume_text}\n\n"
f"请按以下 JSON Schema 输出分析结果(仅输出 JSON):\n"
f"{json.dumps(schema, ensure_ascii=False, indent=2)}"
)
# 如果是重试,提示 AI 重新输出完整 JSON
if retry_hint:
prompt += "\n\n上次输出格式错误或字段不完整,请严格按 Schema 重新输出完整 JSON。"
# 返回拼接好的 prompt
return prompt
# 提取 JSON 内容的函数
def _extract_json_content(raw: str) -> str:
# 去除字符串首尾空白
text = raw.strip()
# 使用正则提取 markdown ```json ... ``` 代码块内容
fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.IGNORECASE)
# 如果有代码块提取出来
if fence_match:
return fence_match.group(1).strip()
# 否则返回原始文本
return text
# 规范化分数字段的函数
def _normalize_score(score) -> int:
# 如果 score 是布尔值,抛出异常
if isinstance(score, bool):
raise ValueError("score 必须是数字")
# 如果 score 是浮点数,则四舍五入转为整数
if isinstance(score, float):
score = int(round(score))
# 如果 score 是数字字符串,转为 int
if isinstance(score, str) and score.isdigit():
score = int(score)
# 如果 score 不是 0-100 间的整数,抛出异常
if not isinstance(score, int) or score < 0 or score > 100:
raise ValueError("score 必须是 0-100 的整数")
# 返回规范化后的分数
return score
# 校验 AI 返回结果格式的函数
def _validate_result(data: dict) -> dict:
# 规范化分数
data["score"] = _normalize_score(data.get("score"))
# 获取推荐字段
recommend = data.get("recommend_apply")
# 如果推荐字段无效,抛出异常
if recommend not in VALID_RECOMMEND:
raise ValueError("recommend_apply 无效")
# 若无推荐理由,抛出异常
if not data.get("recommend_reason"):
raise ValueError("缺少 recommend_reason")
# 获取简历修改项
resume_edits = data.get("resume_edits")
# 如果 resume_edits 不是列表或为空,抛出异常
if not isinstance(resume_edits, list) or not resume_edits:
raise ValueError("resume_edits 不能为空")
# 用于存放校验后的编辑项
normalized_edits = []
# 遍历每一个编辑项,格式化内容
for item in resume_edits:
# 只处理字典类型的项
if not isinstance(item, dict):
continue
# 获取并去除前后空白
section = str(item.get("section", "")).strip()
original = str(item.get("original", "")).strip()
suggested = str(item.get("suggested", "")).strip()
reason = str(item.get("reason", "")).strip()
# 若所有字段都不为空则添加到列表
if section and original and suggested and reason:
normalized_edits.append(
{
"section": section,
"original": original,
"suggested": suggested,
"reason": reason,
}
)
# 若没有有效编辑项,抛出异常
if not normalized_edits:
raise ValueError("resume_edits 格式无效")
# 更新结果为格式化后的编辑项
data["resume_edits"] = normalized_edits
# 获取 skills 字段
skills = data.get("skills")
# skills 必须为字典
if not isinstance(skills, dict):
raise ValueError("skills 格式无效")
# 对 skills 下的各类技能字段做规范化
for key in ("matched", "missing", "bonus"):
items = skills.get(key, [])
# 如果不是列表则置空
if not isinstance(items, list):
skills[key] = []
else:
# 去肥空白,仅保留真实内容
skills[key] = [str(s).strip() for s in items if str(s).strip()]
# 回写规范化后的 skills
data["skills"] = skills
# 返回校验和规范化后的数据
return data
# 格式化 API 错误信息的函数
def _format_api_error(exc: Exception) -> str:
if isinstance(exc, AuthenticationError):
return "DeepSeek API Key 无效,请检查 .env 中的 DEEPSEEK_API_KEY"
if isinstance(exc, APIStatusError):
detail = exc.message
logger.error("DeepSeek API 错误 %s: %s", exc.status_code, detail)
if exc.status_code == 402:
return "DeepSeek 账户余额不足,请充值后重试"
if detail:
return f"AI 分析请求失败:{detail}"
return f"AI 分析请求失败(HTTP {exc.status_code})"
if isinstance(exc, APIConnectionError):
logger.error("DeepSeek 网络错误: %s", exc)
return "AI 分析网络请求失败,请检查网络或 API 地址"
logger.error("DeepSeek 未知错误: %s", exc)
return "AI 分析请求失败,请稍后重试"
# 调用 deepseek 服务的函数
def _call_deepseek(resume_text: str, jd_text: str, *, retry_hint: bool = False) -> dict:
# 动态导入应用配置
from app.config import Config
# 对简历文本做截断处理
resume_text = _truncate(
resume_text, Config.MAX_AI_RESUME_CHARS, "简历"
)
# 对 JD 文本做截断
jd_text = _truncate(jd_text, Config.MAX_JD_LENGTH, "JD")
# 获取 deepseek 的 API key
api_key = current_app.config.get("DEEPSEEK_API_KEY", "").strip()
# 没有配置 key 时抛出异常
if not api_key:
raise ValueError("未配置 DEEPSEEK_API_KEY,请在 .env 中设置")
# 获取 deepseek 的基础 API 地址
base_url = current_app.config.get(
"DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"
).rstrip("/")
# 获取模型名
model = current_app.config.get("DEEPSEEK_MODEL", "deepseek-v4-pro")
client = OpenAI(api_key=api_key, base_url=base_url, timeout=120.0)
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": _build_system_prompt()},
{
"role": "user",
"content": _build_user_prompt(
resume_text, jd_text, retry_hint=retry_hint
),
},
],
response_format={"type": "json_object"},
temperature=0.3,
)
content = response.choices[0].message.content
data = json.loads(_extract_json_content(content))
return _validate_result(data)
# 对外提供的简历与 JD 分析主函数
def analyze_resume_jd(resume_text: str, jd_text: str) -> dict:
# 标记是否重试
retry_hint = False
# 存放上一次的错误对象
last_error: Exception | None = None
# 最多尝试 2 次
for attempt in range(2):
try:
# 调用 deepseek,初次不带重试提示,失败后带提示
return _call_deepseek(resume_text, jd_text, retry_hint=retry_hint)
# 处理网络请求异常
except (APIConnectionError, APIStatusError) as exc:
raise ValueError(_format_api_error(exc)) from exc
# 处理 json 解析错误
except json.JSONDecodeError as exc:
last_error = exc
logger.warning("AI 返回 JSON 解析失败(第 %d 次): %s", attempt + 1, exc)
# 处理结构异常
except KeyError as exc:
last_error = exc
logger.warning("AI 返回结构异常(第 %d 次): %s", attempt + 1, exc)
# 处理校验异常
except ValueError as exc:
last_error = exc
logger.warning("AI 返回校验失败(第 %d 次): %s", attempt + 1, exc)
# 首次失败后,第二次尝试加重试提示
if attempt == 0:
retry_hint = True
continue
# 处理两次尝试均失败后的不同异常类型
if isinstance(last_error, json.JSONDecodeError):
logger.error("返回 JSON 解析失败,已重试: %s", last_error)
raise ValueError("返回格式无法解析,请稍后重试") from last_error
if isinstance(last_error, KeyError):
logger.error("返回结构异常,已重试: %s", last_error)
raise ValueError("返回结构异常,请稍后重试") from last_error
if isinstance(last_error, ValueError):
raise last_error
# 兜底错误
raise ValueError("分析失败,请稍后重试")
4.3. analyzing.html #
templates/analyzing.html
{% extends "base.html" %}
{% block title %}分析中 — 职策{% endblock %}
{% block content %}
<div class="page-card analyzing-card">
{% include "partials/steps.html" %}
<div class="analyzing-body">
<div class="analyzing-icon analyzing-spin">⏳</div>
<h1>系统正在分析中,请稍候...</h1>
<p class="analyzing-hint">预计需要 20–40 秒,请勿关闭页面</p>
<p class="analyzing-meta">JD 预览:{{ jd_preview }}</p>
<ul class="analyzing-steps">
<li class="is-done"><span>✓</span> 解析简历内容</li>
<li class="is-done"><span>✓</span> 提取 JD 关键信息</li>
<li class="is-active"><span>◌</span> 匹配度计算</li>
<li class="is-active"><span>◌</span> 生成改写建议</li>
<li><span>○</span> 生成学习路径</li>
</ul>
<form id="analyze-form" method="post" action="{{ url_for('analyze.run') }}"></form>
<p class="analyzing-status">正在分析,请耐心等待...</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById("analyze-form")?.submit();
</script>
{% endblock %}
4.4. .env #
.env
# 是否开启Flask调试模式,1表示开启
FLASK_DEBUG=1
# Flask服务器监听的主机地址
FLASK_HOST=127.0.0.1
# Flask服务器监听的端口号
FLASK_PORT=5000
# Flask应用的密钥,用于安全相关(如session)
SECRET_KEY=change-me
# 数据库连接URL,格式为“mysql+pymysql://用户名:密码@主机:端口/数据库名”
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/job3
+DEEPSEEK_API_KEY=sk-755da8248cfa4fc4ab0c4ff7015b2a47
+DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
+DEEPSEEK_MODEL=deepseek-v4-pro4.5. config.py #
app/config.py
# 导入操作系统相关模块
import os
# 导入Path对象,用于处理文件和目录路径
from pathlib import Path
# 导入load_dotenv函数,用于加载.env环境变量文件
from dotenv import load_dotenv
# 获取当前文件的上上级目录作为项目的根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 从根目录下加载.env文件,override=True表示覆盖已存在的环境变量
load_dotenv(BASE_DIR / ".env", override=True)
# 定义配置类
class Config:
# 项目根目录路径
BASE_DIR = BASE_DIR
# 模板文件夹路径
TEMPLATE_DIR = BASE_DIR / "templates"
# 静态文件夹路径
STATIC_DIR = BASE_DIR / "static"
# 从环境变量获取SECRET_KEY, 若无则使用默认值"dev-secret-key"
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
# 从环境变量获取数据库连接地址,若无则用默认的MySQL连接字符串
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL",
"mysql+pymysql://root:password@127.0.0.1:3306/job2",
)
# 是否追踪数据库修改,这里默认关闭
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 从环境变量获取FLASK_DEBUG,字符串为"1"时为True,否则为False
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
# 从环境变量获取Flask服务的主机地址,默认为"127.0.0.1"
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
# 从环境变量获取Flask服务的端口号,默认为5000,并转换为int类型
FLASK_PORT = int(os.getenv("FLASK_PORT", "5000"))
# 从环境变量获取最大JD长度,默认为10000
MAX_JD_LENGTH = int(os.getenv("MAX_JD_LENGTH", "10000"))
# 从环境变量获取DEEPSEEK_API_KEY,默认为空字符串,去除首尾空格
+ DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "").strip()
# 从环境变量获取DEEPSEEK_BASE_URL,默认为"https://api.deepseek.com/v1",去除结尾斜杠
+ DEEPSEEK_BASE_URL = os.getenv(
+ "DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"
+ ).rstrip("/")
# 从环境变量获取DEEPSEEK_MODEL,默认为"deepseek-v4-pro",去除首尾空格
+ DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-v4-pro").strip()
# 从环境变量获取最大AI简历字符数,默认为12000,并转为整数
+ MAX_AI_RESUME_CHARS = int(os.getenv("MAX_AI_RESUME_CHARS", "12000"))
# 从环境变量获取JD_BROWSER_HEADLESS,若为"1"则为True,否则为False
+ JD_BROWSER_HEADLESS = os.getenv("JD_BROWSER_HEADLESS", "0") == "1"4.6. init.py #
app/routes/init.py
# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 从app.routes.resume模块导入resume_bp蓝图
from app.routes.resume import resume_bp
# 从app.routes.jd模块导入jd_bp蓝图
from app.routes.jd import jd_bp
# 从app.routes.analyze模块导入analyze_bp蓝图
+from app.routes.analyze import analyze_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
# 将health_bp蓝图注册到Flask应用中
app.register_blueprint(health_bp)
# 将resume_bp蓝图注册到Flask应用中
app.register_blueprint(resume_bp)
# 将jd_bp蓝图注册到Flask应用中
app.register_blueprint(jd_bp)
# 将analyze_bp蓝图注册到Flask应用中
+ app.register_blueprint(analyze_bp)4.7. jd.py #
app/routes/jd.py
# 引入Flask框架相关模块
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
# 引入自定义配置、工具函数和JD解析服务
from app.config import Config
# 引入清除JD会话、获取当前简历的辅助函数和JD解析服务
from app.routes.helpers import clear_jd_session, get_current_resume
# 引入JD解析服务
from app.services.jd_parser import parse_jd_url, validate_jd_text
# 创建一个名为"jd"的蓝图,用于JD相关的路由
jd_bp = Blueprint("jd", __name__)
# 定义/jd路由,支持GET和POST请求
@jd_bp.route("/jd", methods=["GET", "POST"])
def input():
# 获取当前已上传的简历
resume = get_current_resume()
# 如果没有简历,则提示用户先上传简历,并重定向到上传简历页面
if not resume:
flash("请先上传简历", "error")
return redirect(url_for("resume.upload"))
# 处理GET请求逻辑
if request.method == "GET":
# 如果请求参数fresh为"1",则清空JD的会话信息并刷新页面
if request.args.get("fresh") == "1":
clear_jd_session()
return redirect(url_for("jd.input"))
# 获取前端传来的tab参数,默认为"text"
active_tab = request.args.get("tab", "text")
# 判断tab参数是否合法,不合法则默认"text"
if active_tab not in ("text", "link"):
active_tab = "text"
# 从session读取已保存的jd_text和jd_url,若没有则为空字符串
jd_text = session.get("jd_text", "")
jd_url = session.get("jd_url", "")
else:
# 对于非GET请求(即POST),active_tab默认“text”,同时读取session中的jd_text和jd_url
active_tab = "text"
jd_text = session.get("jd_text", "")
jd_url = session.get("jd_url", "")
# 处理POST请求逻辑
if request.method == "POST":
# 获取当前动作类型,默认为“analyze”
action = request.form.get("action", "analyze")
# 如果动作为解析JD链接
if action == "parse_url":
# 获取用户输入的JD链接并去除首尾空格并存入session
jd_url = request.form.get("jd_url", "").strip()
session["jd_url"] = jd_url
try:
# 调用业务方法解析链接内容
jd_text = parse_jd_url(jd_url)
# 解析成功后将内容存入session
session["jd_text"] = jd_text
# 显示成功提示
flash("链接解析成功,已填入 JD 文本", "success")
# 重定向回/jd页面并切换到text标签
return redirect(url_for("jd.input", tab="text"))
except ValueError as exc:
# 解析异常时提示错误,并跳到link标签
flash(str(exc), "error")
return redirect(url_for("jd.input", tab="link"))
try:
# 判断form表单是否传递了jd_text字段
if "jd_text" in request.form:
source_text = request.form.get("jd_text", "")
else:
# 否则从session提取JD文本
source_text = session.get("jd_text", "")
# 校验并规范化JD文本内容
jd_text = validate_jd_text(source_text)
# 校验成功后将JD文本存入session
session["jd_text"] = jd_text
+ return redirect(url_for("analyze.waiting"))
except ValueError as exc:
# 校验失败则提示错误
flash(str(exc), "error")
# 获取表单或者session中已有的JD文本
jd_text = request.form.get("jd_text", session.get("jd_text", ""))
# 如果form提交了jd_text则为text标签,否则为link标签
active_tab = "text" if "jd_text" in request.form else "link"
# 渲染jd.html模板,并传递相关上下文参数
return render_template(
"jd.html",
resume=resume,
current_step=2,
jd_text=jd_text,
jd_url=jd_url,
active_tab=active_tab,
max_jd_length=Config.MAX_JD_LENGTH,
)
5. 匹配报告展示 #
本节实现流程第四步「查看报告」:AI 分析完成后跳转至 /report/<analysis_id>,从数据库读取 Analysis 记录,将匹配分数、投递建议、硬门槛、技能对比、简历改写建议与学习路径渲染为可视化报告页,并提供「分析下一个 JD」「重新上传简历」等后续操作入口。
涉及模块如下:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| 路由 | 按 ID 查询分析记录、映射展示字段 | app/routes/report.py |
| 模板 | 分数环、推荐卡片、表格与改写列表 | templates/report.html |
| 应用工厂 | UTC → 上海时区的 localtime 过滤器 |
app/__init__.py |
| 分析路由 | 分析完成后重定向到报告页 | app/routes/analyze.py(改动) |
| 模型 | 读取 result JSON 与元数据 |
app/models/analysis.py(第 1 节已建) |
报告展示的完整时序如下:
按以下小节顺序逐步创建各文件即可。
5.1. report.py #
app/routes/report.py
# 从flask中导入Blueprint、abort和render_template
from flask import Blueprint, abort, render_template
# 从app.extensions导入db
from app.extensions import db
# 从app.models导入Analysis模型
from app.models import Analysis
# 创建一个名为"report"的蓝图对象
report_bp = Blueprint("report", __name__)
# 定义一个推荐标签的字典,将英文key映射为中文
RECOMMEND_LABELS = {
"apply": "建议投递",
"watch": "观望",
"skip": "不建议",
}
# 定义一个推荐类别的字典,将英文key映射为css类名
RECOMMEND_CLASSES = {
"apply": "recommend-apply",
"watch": "recommend-watch",
"skip": "recommend-skip",
}
# 定义一个用于根据分数返回分数等级字符串的辅助函数
def _score_tier(score: int | None) -> str:
# 如果分数为None,则返回中等分数等级
if score is None:
return "score-mid"
# 如果分数大于等于70,返回高分等级
if score >= 70:
return "score-high"
# 如果分数大于等于50,返回中等分数等级
if score >= 50:
return "score-mid"
# 否则返回低分等级
return "score-low"
# 注册一个GET请求的路由,路径为/report/<int:analysis_id>
@report_bp.get("/report/<int:analysis_id>")
# 定义视图函数show,参数为analysis_id
def show(analysis_id: int):
# 从数据库中获取对应的Analysis对象
analysis = db.session.get(Analysis, analysis_id)
# 如果未找到analysis或者其result属性为None,则抛出404错误
if not analysis or not analysis.result:
abort(404)
# 获取分析结果
result = analysis.result
# 获取推荐类别,如果不存在则默认为"watch"
recommend = result.get("recommend_apply", "watch")
# 获取分数
score = result.get("score")
# 渲染report.html模板,并传入相关参数
return render_template(
"report.html",
analysis=analysis,
result=result,
current_step=4,
recommend_label=RECOMMEND_LABELS.get(recommend, recommend),
recommend_class=RECOMMEND_CLASSES.get(recommend, "recommend-watch"),
score_tier=_score_tier(score),
)
5.2. report.html #
templates/report.html
{% extends "base.html" %}
{% block title %}匹配报告 — 职策 AI{% endblock %}
{% block content %}
<div class="page-card page-card-wide report-page">
{% include "partials/steps.html" %}
<div class="report-top">
<div class="score-card">
<div class="score-ring {{ score_tier }}" style="--score: {{ result.score }}">
<div class="score-value {{ score_tier }}">{{ result.score }}<span>/100</span></div>
</div>
<p class="score-label">匹配度</p>
</div>
<div class="recommend-card {{ recommend_class }}">
<div class="recommend-badge">{{ recommend_label }}</div>
<p class="recommend-reason">{{ result.recommend_reason }}</p>
</div>
</div>
<section class="report-section">
<h2>硬门槛匹配</h2>
<div class="table-wrap">
<table class="report-table">
<thead>
<tr><th>项目</th><th>要求</th><th>匹配</th><th>说明</th></tr>
</thead>
<tbody>
{% set hr = result.hard_requirements or {} %}
{% for key, label in [('experience','经验'),('education','学历'),('city','城市'),('salary','薪资')] %}
{% set item = hr.get(key, {}) %}
<tr>
<td>{{ label }}</td>
<td>{{ item.get('required', '-') }}</td>
<td>
{% if item.get('match') is sameas true %}
<span class="tag tag-green">匹配</span>
{% elif item.get('match') is sameas false %}
<span class="tag tag-red">不匹配</span>
{% else %}
<span class="tag tag-orange">未提及</span>
{% endif %}
</td>
<td>{{ item.get('note') or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="report-section">
<h2>技能匹配</h2>
{% set skills = result.skills or {} %}
<div class="skill-group">
<h3>匹配技能</h3>
<div class="tag-list">
{% for s in skills.get('matched', []) %}
<span class="tag tag-green">{{ s }}</span>
{% else %}
<span class="muted">无</span>
{% endfor %}
</div>
</div>
<div class="skill-group">
<h3>缺失技能</h3>
<div class="tag-list">
{% for s in skills.get('missing', []) %}
<span class="tag tag-red">{{ s }}</span>
{% else %}
<span class="muted">无</span>
{% endfor %}
</div>
</div>
<div class="skill-group">
<h3>加分技能</h3>
<div class="tag-list">
{% for s in skills.get('bonus', []) %}
<span class="tag tag-blue">{{ s }}</span>
{% else %}
<span class="muted">无</span>
{% endfor %}
</div>
</div>
</section>
<section class="report-section">
<div class="section-head">
<h2>简历改写建议</h2>
<button type="button" class="btn btn-outline btn-sm" id="copy-all-edits">复制全部</button>
</div>
<div class="edit-list">
{% for edit in result.resume_edits %}
<div class="edit-item">
<div class="edit-head">
<strong>{{ edit.section }}</strong>
<button type="button" class="btn btn-outline btn-sm copy-edit">复制改后</button>
</div>
<p class="edit-label">原文</p>
<p class="edit-text">{{ edit.original }}</p>
<p class="edit-label">建议改为</p>
<p class="edit-text edit-suggested">{{ edit.suggested }}</p>
<p class="edit-reason">理由:{{ edit.reason }}</p>
</div>
{% endfor %}
</div>
</section>
{% set lp = result.learning_path or {} %}
{% if lp.get('needed') and lp.get('items') %}
<section class="report-section">
<h2>学习路径(提升建议)</h2>
<div class="learning-list">
{% for item in lp.get('items', []) %}
<div class="learning-item">
<div class="learning-title">{{ item.skill }} · 优先级 {{ item.priority }}</div>
<p>预计 {{ item.estimated_weeks }} 周</p>
{% if item.resources %}
<p class="learning-resources">资源:{{ item.resources | join('、') }}</p>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
<p class="disclaimer">AI 分析结果仅供参考,不构成投递建议或录用承诺。请结合个人判断做出决策。</p>
<div class="report-actions">
<a href="{{ url_for('jd.input', fresh=1) }}" class="btn btn-outline">分析下一个 JD</a>
<a href="{{ url_for('resume.upload') }}" class="btn btn-primary">重新上传简历</a>
</div>
<p class="report-meta">分析编号 #{{ analysis.id }} · {{ analysis.created_at|localtime }}</p>
</div>
{% endblock %}
5.3. init.py #
app/init.py
# 从Flask框架中导入Flask类
from flask import Flask
# 从datetime模块导入timezone类,用于处理时区信息
+from datetime import timezone
# 从zoneinfo模块导入ZoneInfo类,用于指定具体的时区
+from zoneinfo import ZoneInfo
# 从app.config模块导入Config配置类
from app.config import Config
# 从app.extensions模块导入db(数据库扩展)
from app.extensions import db
# 从app.routes模块导入register_blueprints函数
from app.routes import register_blueprints
+LOCAL_TZ = ZoneInfo("Asia/Shanghai")
# 定义创建应用工厂函数,可接收一个配置类参数
def create_app(config_class=Config):
# 创建Flask应用实例,指定模板和静态文件目录
app = Flask(__name__,
template_folder=str(config_class.TEMPLATE_DIR),
static_folder=str(config_class.STATIC_DIR))
# 从配置类加载配置信息
app.config.from_object(config_class)
# 定义一个模板过滤器,名称为"localtime"
+ @app.template_filter("localtime")
# 定义一个用于格式化本地时间的函数,接受时间dt和格式fmt参数,fmt默认值为"%Y-%m-%d %H:%M"
+ def format_localtime(dt, fmt="%Y-%m-%d %H:%M"):
# 如果dt为None或False,直接返回空字符串
+ if not dt:
+ return ""
# 将传入的dt对象的时区设置为UTC时间
+ utc_dt = dt.replace(tzinfo=timezone.utc)
# 将UTC时间转换为上海本地时区,并按照指定格式输出字符串
+ return utc_dt.astimezone(LOCAL_TZ).strftime(fmt)
# 初始化数据库,将app绑定到db上
db.init_app(app)
# 注册蓝图到app
register_blueprints(app)
# 在应用上下文中创建所有数据库表
with app.app_context():
db.create_all()
# 返回应用实例
return app5.4. init.py #
app/routes/init.py
# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 从app.routes.resume模块导入resume_bp蓝图
from app.routes.resume import resume_bp
# 从app.routes.jd模块导入jd_bp蓝图
from app.routes.jd import jd_bp
# 从app.routes.analyze模块导入analyze_bp蓝图
from app.routes.analyze import analyze_bp
# 从app.routes.report模块导入report_bp蓝图
+from app.routes.report import report_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
# 将health_bp蓝图注册到Flask应用中
app.register_blueprint(health_bp)
# 将resume_bp蓝图注册到Flask应用中
app.register_blueprint(resume_bp)
# 将jd_bp蓝图注册到Flask应用中
app.register_blueprint(jd_bp)
# 将analyze_bp蓝图注册到Flask应用中
app.register_blueprint(analyze_bp)
# 将report_bp蓝图注册到Flask应用中
+ app.register_blueprint(report_bp)5.5. analyze.py #
app/routes/analyze.py
# 导入Blueprint、flash、redirect、render_template、session、url_for,用于路由、消息、跳转、渲染模板和会话管理
from flask import Blueprint, flash, redirect, render_template, session, url_for
# 导入数据库扩展db
from app.extensions import db
# 导入Analysis模型类
from app.models import Analysis
# 导入获取当前简历的辅助函数
from app.routes.helpers import get_current_resume
# 导入用于简历与JD分析的AI服务
from app.services.ai_analyzer import analyze_resume_jd
# 创建名为"analyze"的蓝图
analyze_bp = Blueprint("analyze", __name__)
# 定义GET请求路由/analyze
@analyze_bp.get("/analyze")
def waiting():
# 获取当前会话下的简历
resume = get_current_resume()
# 如果简历未上传或不存在,提示并跳转到简历上传页面
if not resume:
flash("请先上传简历", "error")
return redirect(url_for("resume.upload"))
# 获取已输入的JD文本并去除首尾空格
jd_text = session.get("jd_text", "").strip()
# 如果JD文本为空,提示并跳转到JD输入页面
if not jd_text:
flash("请先输入 JD", "error")
return redirect(url_for("jd.input"))
# 渲染分析中的页面
return render_template(
"analyzing.html",
resume=resume,
jd_preview=jd_text[:120] + ("..." if len(jd_text) > 120 else ""),
current_step=3,
)
# 定义POST请求路由/analyze/run
@analyze_bp.post("/analyze/run")
def run():
# 获取当前会话下的简历
resume = get_current_resume()
# 如果简历未上传或不存在,提示并跳转到简历上传页面
if not resume:
flash("请先上传简历", "error")
return redirect(url_for("resume.upload"))
# 获取已输入的JD文本并去除首尾空格
jd_text = session.get("jd_text", "").strip()
# 如果JD文本为空,提示并跳转到JD输入页面
if not jd_text:
flash("请先输入 JD", "error")
return redirect(url_for("jd.input"))
# 若简历文本内容为空,提示并跳转到简历上传页面
if not resume.text_content:
flash("简历文本为空,请重新上传", "error")
return redirect(url_for("resume.upload"))
# 尝试调用AI分析服务获取结果
try:
result = analyze_resume_jd(resume.text_content, jd_text)
# 捕获分析服务抛出的ValueError异常,显示异常消息,并重定向回等待页面
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("analyze.waiting"))
# 捕获其它异常,提示分析失败,并重定向回等待页面
except Exception:
flash("分析失败,请稍后重试", "error")
return redirect(url_for("analyze.waiting"))
# 构建Analysis对象保存本次分析的各项数据
analysis = Analysis(
resume_id=resume.id,
jd_text=jd_text,
result=result,
score=result.get("score"),
recommend_apply=result.get("recommend_apply"),
)
# 把Analysis对象加入数据库会话
db.session.add(analysis)
# 尝试提交数据库操作
try:
db.session.commit()
# 如果提交失败,回滚并提示,重定向回等待页面
except Exception:
db.session.rollback()
flash("分析结果保存失败,请稍后重试", "error")
return redirect(url_for("analyze.waiting"))
# 跳转到分析结果页面(
+ return redirect(url_for("report.show", analysis_id=analysis.id))