crawler_reports.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """资料采集 API + 只读挂载目录;原件复制进 workspace 后才交给 Agent。"""
  2. import asyncio
  3. from contextlib import asynccontextmanager
  4. from dataclasses import asdict, dataclass
  5. from datetime import datetime
  6. import hashlib
  7. import json
  8. import math
  9. import os
  10. from pathlib import Path
  11. import re
  12. import time
  13. from urllib.parse import quote, urlsplit
  14. import httpx
  15. import config
  16. from extensions.main_fan.mine_catalog import _write
  17. from extensions.main_fan.report_api import ReportClient, mock_report_client
  18. from obs import get_logger
  19. logger = get_logger(__name__)
  20. def _part(value):
  21. if not isinstance(value, str) or not value.strip() or value in {'.', '..'} or any(c in value for c in '/\\\x00'):
  22. raise ValueError('资料目录中的矿名或类型名称不是有效的单级目录名')
  23. return value
  24. def _check_success(body):
  25. if (not isinstance(body, dict) or body.get('success') is False
  26. or ('code' in body and str(body['code']) not in {'0', '200'})):
  27. raise ValueError('资料服务未返回成功的 JSON 对象')
  28. def _data(body):
  29. _check_success(body)
  30. data = body.get('data', body)
  31. if not isinstance(data, dict):
  32. raise ValueError('资料服务 data 必须是对象')
  33. return data
  34. @dataclass(frozen=True)
  35. class CrawlerSettings:
  36. api_url: str
  37. source_dir: str
  38. report_type: str
  39. period: str = ''
  40. timeout: float = 30
  41. poll_seconds: float = 3
  42. task_timeout: float = 900
  43. max_files: int = 30
  44. max_file_mb: int = 50
  45. @classmethod
  46. def from_config(cls):
  47. value = cls(config.get_str('MAIN_FAN_REPORT_API_URL', 'http://10.244.3.155:19010').rstrip('/'),
  48. str(Path(config.get_str('MAIN_FAN_REPORT_SOURCE_DIR', '/data/source_data')).absolute()),
  49. config.get_str('MAIN_FAN_REPORT_TYPE', '通风机性能测定报告'),
  50. config.get_str('MAIN_FAN_REPORT_PERIOD'),
  51. config.get_float('MAIN_FAN_REPORT_TIMEOUT_SECONDS', 30),
  52. config.get_float('MAIN_FAN_REPORT_POLL_SECONDS', 3),
  53. config.get_float('MAIN_FAN_REPORT_TASK_TIMEOUT_SECONDS', 900),
  54. config.get_int('MAIN_FAN_REPORT_MAX_FILES', 30),
  55. config.get_int('MAIN_FAN_REPORT_MAX_FILE_MB', 50))
  56. parts = urlsplit(value.api_url)
  57. if (parts.scheme not in {'http', 'https'} or not parts.hostname or parts.username
  58. or parts.password or parts.query or parts.fragment):
  59. raise ValueError('MAIN_FAN_REPORT_API_URL 必须是无内嵌凭证的 HTTP(S) 地址')
  60. _part(value.report_type)
  61. if value.period and not re.fullmatch(r'\d{4}-(0[1-9]|1[0-2])', value.period):
  62. raise ValueError('MAIN_FAN_REPORT_PERIOD 必须为 YYYY-MM,留空使用批次截止月份')
  63. if (any(not math.isfinite(n) or n <= 0 for n in (value.timeout, value.poll_seconds, value.task_timeout))
  64. or value.poll_seconds > 60 or not 1 <= value.max_files <= 200 or not 1 <= value.max_file_mb <= 500):
  65. raise ValueError('资料服务超时、轮询间隔或文件上限配置无效')
  66. return value
  67. def report_source(pdf_path=None):
  68. if pdf_path is not None:
  69. path = Path(pdf_path).resolve()
  70. if not path.is_file() or path.suffix.lower() != '.pdf':
  71. raise ValueError('模拟报告接口需要已有的测试 PDF')
  72. with path.open('rb') as stream:
  73. sha = hashlib.file_digest(stream, 'sha256').hexdigest()
  74. return {'mode': 'mock', 'sha256': sha}
  75. mode = config.get_str('MAIN_FAN_REPORT_MODE', 'crawler').lower()
  76. if mode != 'crawler':
  77. raise ValueError('mock 模式需配置 MAIN_FAN_MOCK_REPORT_PDF;正式模式应为 crawler')
  78. return {'mode': 'crawler', **asdict(CrawlerSettings.from_config())}
  79. @asynccontextmanager
  80. async def open_report_source(source, pdf_path=None):
  81. if source['mode'] == 'mock':
  82. async with mock_report_client(pdf_path) as http:
  83. yield ReportClient(http)
  84. else:
  85. settings = CrawlerSettings(**{key: value for key, value in source.items() if key != 'mode'})
  86. async with httpx.AsyncClient(base_url=settings.api_url + '/', timeout=settings.timeout,
  87. trust_env=False, follow_redirects=False) as http:
  88. yield CrawlerReportClient(http, settings)
  89. class CrawlerReportClient:
  90. def __init__(self, http, settings):
  91. self.http, self.settings = http, settings
  92. self.cache = {}
  93. self.lock = asyncio.Lock()
  94. async def _request(self, method, path, **kwargs):
  95. started = time.monotonic()
  96. try:
  97. response = await self.http.request(method, path.lstrip('/'), **kwargs)
  98. except httpx.RequestError as exc:
  99. logger.warning('资料服务连接失败', extra={'kv': {'event': 'report.http.failed',
  100. 'method': method, 'path': path, 'error_type': type(exc).__name__}})
  101. raise ValueError(f'资料服务 {method} {path} 连接失败:{type(exc).__name__}') from None
  102. logger.debug('资料服务请求', extra={'kv': {'event': 'report.http', 'method': method,
  103. 'path': path, 'http_status': response.status_code,
  104. 'duration_ms': round((time.monotonic() - started) * 1000)}})
  105. if response.status_code != 409 and not 200 <= response.status_code < 300:
  106. raise ValueError(f'资料服务 {method} {path} 返回 HTTP {response.status_code}')
  107. try:
  108. body = response.json()
  109. except ValueError:
  110. raise ValueError(f'资料服务 {method} {path} 未返回 JSON') from None
  111. return response.status_code, body
  112. async def list_for_mine(self, mine, cutoff, receipt_dir):
  113. period = self.settings.period or datetime.fromisoformat(cutoff).strftime('%Y-%m')
  114. key = (str(mine['dept_id']), mine['mine_name'], period)
  115. # 同一矿的多个通风井只采集一次,报告归属仍由报告内容核对。
  116. async with self.lock:
  117. if key not in self.cache:
  118. self.cache[key] = await self._collect(mine, period, Path(receipt_dir))
  119. return self.cache[key]
  120. async def _collect(self, mine, period, folder):
  121. started = time.monotonic()
  122. _, mines_body = await self._request('GET', '/mines', params={'with_tree': 0})
  123. rows = _data(mines_body).get('mines')
  124. if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
  125. raise ValueError('资料服务缺少 data.mines 数组')
  126. matches = [row for row in rows if str(row.get('dept_id')) == str(mine['dept_id'])]
  127. if len(matches) != 1 or matches[0].get('mine_name') != mine['mine_name']:
  128. raise ValueError('资料服务与 deptTree 的矿井 ID/全称不一致,停止采集以免取错矿')
  129. name, kind = _part(matches[0]['mine_name']), _part(self.settings.report_type)
  130. base = {'mine_name': name, 'dept_id': str(mine['dept_id']), 'year': int(period[:4])}
  131. status, sync = await self._request('POST', '/doc-types/sync', json=base)
  132. if status == 409:
  133. raise ValueError('资料类型同步仍在运行,请稍后恢复本批次')
  134. _check_success(sync)
  135. _, types = await self._request('GET', '/doc-types', params={'mine': name, 'year': base['year'], 'name': '通风机'})
  136. rows = _data(types).get('items')
  137. if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
  138. raise ValueError('资料类型接口缺少 data.items 数组')
  139. matches = [row for row in rows if row.get('level3') == kind]
  140. if len(matches) != 1 or not str(matches[0].get('file_name_id', '')).isdigit():
  141. raise ValueError('未唯一找到通风机测定报告资料类型,不能使用其他矿的类型编号')
  142. request = {**base, 'file_name_ids': [int(matches[0]['file_name_id'])], 'period': period, 'dry_run': False}
  143. record_path = folder / 'task.json'
  144. previous = None
  145. if record_path.is_file():
  146. previous = json.loads(record_path.read_text(encoding='utf-8'))
  147. if previous.get('request') != request:
  148. raise ValueError('本批次已冻结的报告采集参数发生变化,请新建批次')
  149. task_id = previous.get('task_id') if previous else None
  150. if not previous:
  151. _write(record_path, {'request': request, 'task_id': None, 'status': 'pending'})
  152. deadline = time.monotonic() + self.settings.task_timeout
  153. busy_logged = False
  154. final = None
  155. while True:
  156. if time.monotonic() >= deadline:
  157. raise ValueError('报告采集等待超时;采集参数及已取得的任务 ID 已保留,可恢复本批次')
  158. if not task_id:
  159. status, body = await self._request('POST', '/crawl', json=request)
  160. if status == 409:
  161. # 文档未保证 409 返回任务 ID;等待后仅重试同一幂等请求。
  162. if not busy_logged:
  163. logger.info('资料服务忙,等待现有采集任务结束', extra={'kv': {'event': 'report.crawl.busy', 'period': period}})
  164. busy_logged = True
  165. await asyncio.sleep(self.settings.poll_seconds)
  166. continue
  167. task_id = _data(body).get('task_id')
  168. if not isinstance(task_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', task_id):
  169. raise ValueError('采集接口缺少有效 task_id')
  170. _write(record_path, {'request': request, 'task_id': task_id, 'status': 'running'})
  171. logger.info('报告采集已启动', extra={'kv': {'event': 'report.crawl.started',
  172. 'crawl_task_id': task_id, 'period': period, 'report_type': kind}})
  173. _, final = await self._request('GET', '/crawl/' + quote(task_id, safe=''))
  174. state = _data(final).get('status')
  175. _write(record_path, {'request': request, 'task_id': task_id, 'status': state, 'response': final})
  176. if state in {'done', 'partial', 'failed'}:
  177. if state != 'done':
  178. raise ValueError(f'报告采集终态为 {state},详见 report-source/task.json,不能当作完整资料分析')
  179. break
  180. if state not in {'queued', 'pending', 'running'}:
  181. raise ValueError('报告采集返回未知任务状态,详见 report-source/task.json')
  182. await asyncio.sleep(self.settings.poll_seconds)
  183. # 快速调用文档未定义返回文件清单,按其约定的唯一矿/年/类型目录读取。
  184. # 集合可能包含往期原件;记录为年度已保存集合,不冒称本期新增附件。
  185. records = await asyncio.to_thread(self._files, name, base['year'], kind)
  186. attach = _data(final).get('attach')
  187. attachment_count = attach if type(attach) is int else len(attach) if isinstance(attach, list) else None
  188. if attachment_count and not records:
  189. raise ValueError('采集任务返回有附件,但挂载目录没有PDF,请检查目录映射及附件格式')
  190. receipt = {'request': request, 'task_id': task_id, 'response': final,
  191. 'collection_scope': '同矿同年度同资料类型目录的已保存PDF集合,可能包含往期原件;报告日期及井/风机归属须核对原文',
  192. 'files': [{key: val for key, val in row.items() if key != '_local_source'} for row in records]}
  193. _write(folder / 'files.json', receipt)
  194. logger.info('报告采集完成', extra={'kv': {'event': 'report.crawl.completed', 'crawl_task_id': task_id,
  195. 'period': period, 'pdf_count': len(records), 'duration_ms': round((time.monotonic() - started) * 1000)}})
  196. return records, {'source': 'crawler_mount', **request}, receipt
  197. def _files(self, mine_name, year, kind):
  198. mount = Path(self.settings.source_dir)
  199. if not mount.is_dir():
  200. raise ValueError('报告挂载目录不存在,请挂载 /home/chief/source_data 并配置 MAIN_FAN_REPORT_SOURCE_DIR')
  201. root = mount.resolve()
  202. source = root / mine_name / str(year) / kind
  203. if any(part.is_symlink() for part in (source, source.parent, source.parent.parent)):
  204. raise ValueError('报告矿井/年份/类型目录不能是符号链接')
  205. if not source.exists():
  206. return []
  207. if not source.is_dir() or not source.resolve().is_relative_to(root):
  208. raise ValueError('报告资料目录不是挂载范围内的有效目录')
  209. records = []
  210. for path in sorted(source.iterdir()):
  211. if path.suffix.lower() != '.pdf':
  212. continue
  213. if path.is_symlink() or not path.is_file():
  214. raise ValueError('报告目录存在无效 PDF 或符号链接')
  215. if len(records) >= self.settings.max_files:
  216. raise ValueError('报告数量超过 MAIN_FAN_REPORT_MAX_FILES,不截断报告集合')
  217. if not 0 < path.stat().st_size <= self.settings.max_file_mb * 1024 * 1024:
  218. raise ValueError('报告文件为空或超过配置大小上限')
  219. records.append({'filename': path.name, 'is_mock': False, 'binding': 'mine_directory_content_unverified',
  220. 'note': '来自核对ID和全称后的矿级资料目录;仍须核对PDF正文的报告日期、通风井及风机归属。',
  221. 'source_relative_path': str(path.relative_to(root)), '_local_source': str(path)})
  222. return records
  223. async def download(self, record, target):
  224. def copy():
  225. # 文件在等待期间也可能变化;复制时重新检查边界、大小及签名。
  226. source = Path(record['_local_source'])
  227. root = Path(self.settings.source_dir).resolve()
  228. expected = root / record['source_relative_path']
  229. if (not expected.is_relative_to(root) or source.is_symlink()
  230. or source.resolve() != expected or not source.is_file()):
  231. raise ValueError('报告原件不在有效挂载目录内')
  232. target.parent.mkdir(parents=True, exist_ok=True)
  233. partial = target.with_suffix('.part')
  234. sha, size = hashlib.sha256(), 0
  235. try:
  236. with source.open('rb') as incoming, partial.open('wb') as outgoing:
  237. before = os.fstat(incoming.fileno())
  238. if incoming.read(5) != b'%PDF-':
  239. raise ValueError('挂载报告不是 PDF 原件')
  240. incoming.seek(0)
  241. while chunk := incoming.read(1024 * 1024):
  242. size += len(chunk)
  243. if size > self.settings.max_file_mb * 1024 * 1024:
  244. raise ValueError('复制过程中报告超过配置大小上限')
  245. sha.update(chunk)
  246. outgoing.write(chunk)
  247. after = source.stat()
  248. if ((before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
  249. != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
  250. or size != after.st_size or source.resolve() != expected):
  251. raise ValueError('报告复制过程中原件发生变化,请稍后重试')
  252. partial.replace(target)
  253. finally:
  254. partial.unlink(missing_ok=True)
  255. logger.debug('PDF 已复制到 workspace', extra={'kv': {'event': 'report.copied',
  256. 'filename': source.name, 'size': size, 'sha256': sha.hexdigest(), 'target': str(target)}})
  257. return sha.hexdigest()
  258. return await asyncio.to_thread(copy)