--- title: "03-ann_model" created: 2026-04-02 tags: - 项目 aliases: - ann_model --- # ann_model.py ### `ann_model.py` — 核心调度器(主入口) 整个解析系统的大脑,串联所有模块:公告级解析 → 职位列表解析 → Markdown 格式处理三阶段,输出标准 JSON 模型文件。三阶段详解见 [[00-parsegpt|parsegpt 主篇]]。 ## 代码 ```python # -*- coding: utf-8 -*- """ 校招公告解析核心模块 功能:从MD/HTML文本中,通过大模型提取:公司信息、职位列表、应聘方式、二维码、学历、毕业届等结构化数据 输出:标准JSON模型文件,用于后续入库/展示 """ import json import sys sys.path.append('../') import os import re import datetime # 工具类:日志、MD5、IP、版本、去重、中文判断 from utils import ner_logger, getMD5Str, get_local_ip, QZ_VERISON, deduplicate_strings, all_zh # HTML/MD工具:文本清洗、MD提取、GPT后修复 from utils_html import clean_text, get_md_content, fix_md_after_gpt # 简历工具:学历标准化 from utils_resume import fix_diploma # 时间工具:日期格式化、过期判断 from utils_date import fix_data_format, is_near_month, get_current_time_string # 大模型Prompt模板(校园公告、职位、文章、表格等) from parsegpt.template import get_template_campus_wb10 as get_template_campus from parsegpt.template import get_template_full_html, get_template_table_html from parsegpt.template import get_template_job_wb as get_template_job from parsegpt.template import get_template_article as get_template_article from parsegpt.template import get_template_article_fix as get_template_article_fix # 大模型API from api.doubao_api import call_gpt from api.doubao_api import call_gpt as doubao_call_gpt from api.qwen_api import call_gpt as qwen_call_gpt # 云端查重 from api.quanzhi_api import check_cloud # 职位解析子模块 from parsegpt.ann_model_job import parse_cjob # ====================== 主函数:公告结构化解析 ====================== def parse_announcement(_mdfile, _htmlfile, _model_file, _info, _full_text, proc_type, sch_info, _expired_file, _stat): """ 【核心入口】解析招聘公告,输出结构化JSON 流程:清洗文本 → 大模型提取公告主体 → 过期/重复/三无校验 → 职位列表提取 → MD优化 → 输出模型 :return: (是否成功, 信息描述) """ _ann_dict = {} # 最终输出的模型字典 _title = _info['announcement_name'] _text = clean_text(_full_text) # 清洗纯文本 # 调优文本(页面指定区块提取的内容,辅助大模型) _tuning_md = "" _tuning_full_text = "" if 'tuning_content' in _info and len(_info['tuning_content']) > 0: _tuning_t = _info['tuning_content'] if len(_tuning_t) > 6000: # 超长截断,防止大模型超限 _tuning_t = _tuning_t[:6000] _tuning_full_text = "\n".join(_tuning_t) # ========== 1. 大模型解析:公告主体(公司、时间、应聘方式等) ========== try: _all_text = f"{_text}\n{_tuning_full_text}" _t_text = get_template_campus(_title, _all_text) # 构造Prompt (_ok_flag, json_str) = call_gpt(_t_text, True) # 调用大模型 if not _ok_flag: return False, f"通过大模型获取公告信息Error:{_ok_flag}\n{json_str}" json_data = json.loads(json_str, strict=False) # 转JSON # 校验:返回字段太少,视为解析失败 if len(json_data) < 10: return False, f"通过大模型获取公告信息项目太少Error:{_ok_flag}\n{json_str}" # 校验:公告过期(截止时间超过1个月) if 'OnlineEndDate' in json_data and json_data['OnlineEndDate'].strip() != '': _datestr = fix_data_format(json_data['OnlineEndDate'].strip()) if not is_near_month(_datestr, 1): return False, f"处理的公告过期:{_datestr}" # 处理:多公司公告 → 统一命名为“校招公告” _com_name = json_data['ComName'].strip() if 'HasMultipleCompanies' in json_data and json_data['HasMultipleCompanies'].strip() == "是": if all_zh(_com_name) and len(_com_name) < 25: ner_logger.info(f"处理的公告存在多公司的情况,排除掉:{_com_name}") else: json_data['ComName'] = "校招公告" json_data['ComDesc'] = "" json_data['ComIndustry'] = "" # 公司名过长/非中文 → 视为多公司 elif not all_zh(_com_name) and len(_com_name) > 40: json_data['ComName'] = "校招公告" json_data['ComDesc'] = "" json_data['ComIndustry'] = "" json_data['HasMultipleCompanies'] = "是" # 提取二维码信息(图片解析结果) _qrdict = get_qrcode_info(_info) json_data['ApplyTypeQrcode'] = _qrdict json_data['FullText'] = _all_text # 填充来源、标题、IP、时间等附加字段 set_other_info(sch_info, _info, json_data, _text, _htmlfile) # ========== 核心过滤:三无公告(无链接、无文本、无邮箱、无二维码、无联系方式) ========== if not json_data['ApplyTypeLink'] and not json_data['ApplyTypeText'] and not json_data['ApplyTypeEmail'] and len(json_data['ApplyTypeQrcode']) == 0 and len(json_data['ApplyContacts']) == 0: return False, f"处理的公告存在无链接、无应聘文本、无应聘邮箱、无联系方式的的情况" # 过滤:无公司 / 无职位标题 if not json_data['ComName'] or not json_data['JobTitle']: return False, f"处理的公告存在无公司、无公告名称的情况" # 云端查重:公告已存在 _ok, msg = check_cloud(json_data['JobLink'], json_data['ComName'], json_data['FullText'], json_data['GraduationYear']) if not _ok: return False, f"云端检测公告重复:{msg}" # 学历标准化(大专/本科/硕士...) fix_diploma_data_map(json_data) # 存入公告主体 _ann_dict['ann'] = json_data except json.JSONDecodeError as e: import traceback traceback.print_exc() return False, f"通过大模型获取公告信息Error:\n{e}" # ========== 白名单学校:单独走职位解析流程 ========== _white_list = ['sch_98534', 'sch_00131', 'sch_98531', 'sch_21131', 'sch_98507', 'sch_00114', 'sch_00102'] if _info['channel'] in _white_list and json_data['AnnouncementType'] == "职位": ner_logger.info(f"处理的公告是职位信息:{_title}") # 补充服务信息 json_data['server_ip'] = get_local_ip() json_data['qz_version'] = QZ_VERISON json_data['process_time'] = get_current_time_string() json_data['file_path'] = _htmlfile # MD格式化 common_process_fix(_mdfile, json_data) # 调用职位专用解析 _ok, annjson_data = parse_cjob(_htmlfile, json_data) if _ok: # 保存模型文件 with open(_model_file, 'w', encoding='utf-8') as fw: json.dump(annjson_data, fw, ensure_ascii=False, indent=4) ner_logger.info(f"处理文章生成公告内职位模型文件成功,{_model_file}") # 标记成功 _job_other_file = _expired_file.replace(".json.expired", ".json.job") with open(_job_other_file, 'w', encoding='utf-8') as fw: fw.write(f"job 110\n") return True, f"{_htmlfile}公告内职位解析成功" return False, f"{_htmlfile}公告里面的职位解析失败" else: # 非白名单,清理职位标记文件 _job_other_file = _expired_file.replace(".json.expired", ".json.job") if os.path.exists(_job_other_file): os.remove(_job_other_file) # ========== 2. 大模型解析:职位列表 ========== try: _t_text = get_template_job(_title, _text) _ok, json_data = get_all_job_info(_t_text) if not _ok: return False, f"通过大模型获取职位信息列表Error:\n{json_str}" # 无职位 → 尝试从调优文本提取 if len(json_data) == 0 and len(_tuning_full_text) > 10: _t_text = get_template_job(_title, _tuning_full_text) _ok, json_data = get_all_job_info(_t_text) if not _ok: return False, f"通过大模型获取职位信息列表,从优化的内容里面Error:\n{json_str}" _tuning_md = fix_md_using_gpt_full(_tuning_full_text) # 职位列表后处理 set_other_job_list(json_data) _ann_dict['jobs'] = json_data except json.JSONDecodeError as e: return False, f" 通过大模型获取职位信息列表总的 Error:\n{e}" # ========== 3. 附加信息 & MD优化 ========== _info['mdfile_path'] = _model_file _ann_dict['other'] = _info # 附加信息 # 读取原始HTML with open(_htmlfile, 'r', encoding='utf-8') as f: _html_file_content = f.read() # 根据页面类型选择MD生成策略 if 'is_large_image' in _info and _info['is_large_image'] == 'OK' or 'type_url' in _info and _info['type_url'] == 'wxwz': common_process(_mdfile, _ann_dict, _tuning_md) elif 'is_external_link' in _info and _info['is_external_link'] == 'OK': _ann_dict['mdfile'] = fix_md_using_gpt_full(_text) ner_logger.info(f"使用大模型对全文进行markdown提取,{_htmlfile}") elif 'text_to_markdown' in sch_info and sch_info['text_to_markdown'] == 'OK': _ann_dict['mdfile'] = fix_md_using_gpt_full(_text) ner_logger.info(f"使用大模型含table的全文进行markdown提取,{_htmlfile}") elif ('has_table' in _info and _info['has_table'] == 'OK' or 'html_to_markdown' in sch_info and sch_info['html_to_markdown'] == 'OK') and len(_tuning_full_text) == 0: if len(_html_file_content) < 20000: _ann_dict['mdfile'] = fix_md_using_gpt_table(_html_file_content) ner_logger.info(f"使用大模型对含table进行markdown提取,{_htmlfile}") else: _ann_dict['mdfile'] = fix_md_using_gpt_full(_text) ner_logger.info(f"使用大模型含table的全文进行markdown提取1,{_htmlfile}") else: common_process(_mdfile, _ann_dict, _tuning_md) # HTML内容(为空,节省空间) _ann_dict['htmlfile'] = "" # ========== 4. 输出最终模型文件 ========== if len(_ann_dict) >= 5: with open(_model_file, 'w', encoding='utf-8') as fw: json.dump(_ann_dict, fw, ensure_ascii=False, indent=4) ner_logger.info(f"处理文章生成模型文件成功,{_model_file}") # 清理临时文件 os.remove(_mdfile) os.remove(_htmlfile) return True, f"{_mdfile}解析成功" else: return False, f"{_mdfile}解析失败" # ====================== 通用MD处理 ====================== def common_process(_mdfile, _ann_dict, _tuning_md): """读取MD + GPT格式化 + 后处理""" _ann_dict['mdfile'] = get_md_content(_mdfile, "") if len(_ann_dict['mdfile']) < 20000: _ann_dict['mdfile'] = fix_md_using_gpt(_ann_dict['mdfile']) _ann_dict['mdfile'] = fix_md_after_gpt(_ann_dict['mdfile'], _tuning_md) def common_process_fix(_mdfile, _ann_dict): """精简版MD格式化(白名单学校专用)""" _ann_dict['mdfile'] = get_md_content(_mdfile, "") _ann_dict['mdfile'] = fix_md_using_gpt_fix(_ann_dict['mdfile']) ner_logger.info(f"处理文章生成模型文件成功,{_ann_dict['mdfile']}") # ====================== 职位列表解析 ====================== def get_all_job_info(_t_text): """调用大模型提取职位列表""" ner_logger.info(f"开始通过大模型获取职位信息列表 {_t_text}") (_ok_flag, json_str) = call_gpt(_t_text, True) if not _ok_flag: return False, {} json_data = json.loads(json_str, strict=False) # 兼容Map包裹单层List的情况 if isinstance(json_data, dict) and len(json_data) == 1: for k, v in json_data.items(): if isinstance(v, list): json_data = v break fix_diploma_data_list(json_data) # 学历标准化 json_data = fix_jobname_data_list(json_data) # 过滤空职位名 return True, json_data # ====================== MD大模型优化 ====================== def fix_md_using_gpt(_content): """豆包:优化MD格式""" if len(_content) > 20000: return _content chi = re.findall(r'[\u4E00-\u9FFF]', _content) if len(chi) < 100: return _content _t_text = get_template_article(_content) ok, json_str = doubao_call_gpt(_t_text, True) if ok: try: json_data = json.loads(json_str, strict=False) if 'mdContent' in json_data and isinstance(json_data['mdContent'], str): return json_data['mdContent'] elif 'mdcontent' in json_data and isinstance(json_data['mdcontent'], str): return json_data['mdcontent'] except: ner_logger.error(f"fix_md_using_gpt Error:{json_str}") return _content def fix_md_using_gpt_fix(_content): """千问:精简格式化(白名单专用)""" if len(_content) > 20000: return _content chi = re.findall(r'[\u4E00-\u9FFF]', _content) if len(chi) < 100: return _content _t_text = get_template_article_fix(_content) ok, json_str = qwen_call_gpt(_t_text, True) if ok: try: json_data = json.loads(json_str, strict=False) if 'mdContent' in json_data and isinstance(json_data['mdContent'], str): return json_data['mdContent'] except: ner_logger.error(f"fix_md_using_gpt Error") return _content def fix_md_using_gpt_full(_content): """全文→MD""" _t_text = get_template_full_html(_content) return fix_md_using_gpt_full_inner(_content, _t_text) def fix_md_using_gpt_table(_content): """表格→MD""" _t_text = get_template_table_html(_content) return fix_md_using_gpt_full_inner(_content, _t_text) def fix_md_using_gpt_full_inner(_content, _t_text): if len(_content) > 20000: return _content ok, json_str = doubao_call_gpt(_t_text, True) if ok: try: json_data = json.loads(json_str, strict=False) if 'mdContent' in json_data: return json_data['mdContent'] except: ner_logger.error(f"fix_md_using_gpt full Error") return _content # ====================== 数据填充与修复 ====================== def set_other_info(sch_info, _info, json_data, _text, _htmlfile): """填充来源、标题、MD5、发布时间、微信信息、地区、专业去重等""" json_data['FileId'] = getMD5Str(_text) json_data['AnnType'] = 'wx_ann' if _info['type_url'] == 'wxwz' else 'sch_ann' json_data['JobLink'] = _info['full_url'] json_data['JobTitle'] = _info['announcement_name'] json_data['JobDescribe'] = "" json_data['JobReq'] = "" # 页面提取的公司名覆盖 if 'hd_company' in _info and _info['hd_company']: json_data['HdCompany'] = _info['hd_company'] if not json_data.get('ComName'): json_data['ComName'] = json_data['HdCompany'] # 公告标题覆盖 if 'hd_ann' in _info and len(_info['hd_ann']) > 10: json_data['JobTitle'] = _info['hd_ann'] # 发布时间 if 'publish_time' in _info: json_data['PublishTime'] = _info['publish_time'] # 微信相关信息 json_data['WeixinId'] = _info.get('wx_id', '') json_data['WeixinName'] = _info.get('wx_name', sch_info.get('sch_name', '')) json_data['WeixinTitle'] = _info.get('wx_title', '') json_data['WeixinPublishTime'] = _info.get('wx_public_time', '') # 工作地点 = 公司地点(为空时) if json_data.get('WorkPlace') == '' and json_data.get('ComPlace') != '': json_data['WorkPlace'] = json_data['ComPlace'] # 专业/职位名去重 json_data['MajorRequirement'] = deduplicate_strings(json_data.get('MajorRequirement', '')) json_data['JobName'] = deduplicate_strings(json_data.get('JobName', '')) # 公告/职位类型智能修正 if json_data.get('AnnouncementType') == "公告" and len(json_data['JobTitle']) <= 10: if any(k in json_data['JobTitle'] for k in ['人员','师','岗']): json_data['AnnouncementType'] = '职位' # 薪资标准化 if json_data.get('Salary') in ['面谈', '待遇从优']: json_data['Salary'] = '面议' # 毕业年份处理 fix_graduate_year(json_data) # 来源链接提取 if _info['channel'] != 'sch_88888': if json_data.get('SourceLink'): _info['source_link'] = json_data['SourceLink'] else: _ok, t, l = get_source_link(_htmlfile) if _ok: _info['source_link'] = l _info['source_link_text'] = t.replace("来源于", "") # 服务信息 _info['server_ip'] = get_local_ip() _info['qz_version'] = QZ_VERISON _info['process_time'] = get_current_time_string() def set_other_job_list(json_data): """职位列表清空长文本,节省性能""" for j in json_data: j['JobDescribe'] = "" j['JobReq'] = "" # ====================== 二维码提取 ====================== def get_qrcode_info(info): """从图片解析结果中提取二维码链接和图片""" if 'img_urls' in info['props']: for k, m in info['props']['img_urls'].items(): if m.get('full_qr') == 'Y': return {'qr_url': k, 'qr_pic': m.get('qz_img_url', k), 'all_qr_pics': {k: m.get('qz_img_url', k)}} if m.get('full_qr') == 'H' and 'inside_qr_link' in m: return {'qr_url': m['inside_qr_link'], 'qr_pic': m['inside_qr_pic'], 'all_qr_pics': m['all_qr_pics']} return {} # ====================== 数据标准化 ====================== def fix_diploma_data_list(json_data): """列表学历标准化""" for x in json_data: if 'Degree' in x: x['Degree'] = fix_diploma(x['Degree']) def fix_diploma_data_map(item): """单条学历标准化""" if 'Degree' in item: item['Degree'] = fix_diploma(item['Degree']) def fix_jobname_data_list(json_data): """过滤空职位名""" return [j for j in json_data if j.get('JobTitle')] def fix_graduate_year(json_data): """智能提取毕业年份:标题正则→当前时间自动补全""" gy = json_data.get('GraduationYear', []) if isinstance(gy, str): gy = [gy] if gy else [] json_data['GraduationYear'] = gy # 从标题提取 2025/2026... if 'JobTitle' in json_data: matches = re.findall(r'202[5-9]', json_data['JobTitle']) if matches: json_data['GraduationYear'] += matches # 兜底:当前年(8月后+1) if not json_data['GraduationYear']: y = datetime.datetime.now().year if datetime.datetime.now().month >= 8: y += 1 json_data['GraduationYear'] = [str(y)] # ====================== 辅助工具 ====================== def get_wx_xqxx(_data): """获取微信预约链接""" if 'wx_code_file_config' in _data: f = _data['wx_code_file_config'] if os.path.exists(f): return True, json.load(open(f)).get('yqym_url', '') return False, "" def get_source_link(_htmlfile): """提取来源链接(微信/网络)""" with open(_htmlfile, encoding='utf-8') as f: html = f.read() for href, text in re.findall(r']*href="([^"]+)"[^>]*>(.*?)', html, re.S): if 'mp.weixin.qq.com' in href: return True, "来源于微信文章", href for href, text in re.findall(r']*href="([^"]+)"[^>]*>(.*?)', html, re.S): if '来源' in text: return True, "来源于网络", href return False, "", "" ``` --- **项目分区导航**:[[02-ann_md|ann_md]] ⬅️ | 03-ann_model | ➡️ [[04-ann_model_job|ann_model_job]]