update 26.7.25

This commit is contained in:
kun
2026-07-26 00:30:28 +08:00
parent 59b80ba269
commit 4218e6cdfc
66 changed files with 13639 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Local utility scripts for market_api."""
+445
View File
@@ -0,0 +1,445 @@
from __future__ import annotations
import argparse
import json
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
# ===== 直接运行时主要改这里 =====
API_BASE = "http://127.0.0.1:8000"
OUTPUT_PATH: str | Path | None = None
OUTPUT_DIR: str | Path | None = None
YEAR = 2026
MONTH = 5
CT = ""
SCOPE = "行政区"
SCOPE_VALUE = "崇明"
TIMEOUT = 15.0
# ================================
VALID_SCOPES = {"上海", "行政区", "属地"}
VALID_CT_VALUES = {"", ""}
class AnalysisTxtError(RuntimeError):
"""Raised when analysis txt generation cannot continue."""
@dataclass(frozen=True)
class ApiCall:
path: str
params: dict[str, object]
@dataclass(frozen=True)
class AnalysisTask:
filename: str
prompt: str
calls: tuple[ApiCall, ...]
@dataclass(frozen=True)
class RunConfig:
api_base: str
output_path: Path
year: int
month: int
ct: str
scope: str
scope_value: str | None
timeout: float
Fetcher = Callable[[str, str, dict[str, object], float], object]
def _normalize_month(month: int) -> int:
if not 1 <= month <= 12:
raise ValueError("month 必须在 1-12 之间")
return month
def _normalize_scope(scope: str, scope_value: str | None) -> tuple[str, str | None]:
normalized_scope = scope.strip()
if normalized_scope not in VALID_SCOPES:
raise ValueError("scope 必须是 上海、行政区 或 属地")
normalized_value = scope_value.strip() if scope_value is not None else ""
if normalized_scope == "上海":
return normalized_scope, None
if not normalized_value:
raise ValueError(f"scope 为{normalized_scope}时 scope_value 不能为空")
return normalized_scope, normalized_value
def _normalize_ct(ct: str) -> str:
normalized_ct = ct.strip()
if normalized_ct not in VALID_CT_VALUES:
raise ValueError("ct 必须是 是 或 否")
return normalized_ct
def _scope_label(scope: str, scope_value: str | None) -> str:
if scope == "上海":
return "上海"
return f"{scope}{scope_value}"
def _safe_filename_segment(value: str) -> str:
safe_value = re.sub(r'[\\/:*?"<>|]+', "_", value)
safe_value = re.sub(r"\s+", "_", safe_value).strip("._ ")
return safe_value or "未命名"
def _base_params(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
params: dict[str, object] = {
"year": year,
"month": month,
"scope": scope,
}
if scope_value is not None:
params["scope_value"] = scope_value
return params
def build_analysis_tasks(
*,
year: int,
month: int,
ct: str,
scope: str,
scope_value: str | None,
) -> list[AnalysisTask]:
normalized_month = _normalize_month(month)
normalized_ct = _normalize_ct(ct)
normalized_scope, normalized_scope_value = _normalize_scope(scope, scope_value)
label = _scope_label(normalized_scope, normalized_scope_value)
tender_params = _base_params(
year=year,
month=normalized_month,
scope=normalized_scope,
scope_value=normalized_scope_value,
)
award_params = {
**tender_params,
"ct": normalized_ct,
}
return [
AnalysisTask(
filename="01_公开市场整体态势.txt",
prompt=f"请基于以下数据分析{label}公开市场整体态势:",
calls=(
ApiCall("/tender/overall", dict(tender_params)),
ApiCall("/tender/region", dict(tender_params)),
),
),
AnalysisTask(
filename="02_公开市场行业分布.txt",
prompt=f"请基于以下数据分析{label}公开市场行业分布:",
calls=(ApiCall("/tender/industry", dict(tender_params)),),
),
AnalysisTask(
filename="03_公开市场单月动向.txt",
prompt=f"请基于以下数据分析{label}公开市场单月动向:",
calls=(ApiCall("/tender/overall", dict(tender_params)),),
),
AnalysisTask(
filename="04_运营商市场整体态势.txt",
prompt=f"请基于以下数据分析{label}运营商市场整体态势:",
calls=(ApiCall("/award/overall", dict(award_params)),),
),
AnalysisTask(
filename="05_运营商市场行业分布.txt",
prompt=f"请基于以下数据分析{label}运营商市场行业分布:",
calls=(ApiCall("/award/industry", dict(award_params)),),
),
AnalysisTask(
filename="06_运营商市场单月动向.txt",
prompt=f"请基于以下数据分析{label}运营商市场单月动向:",
calls=(ApiCall("/award/overall", dict(award_params)),),
),
]
def _request_url(api_base: str, path: str, params: dict[str, object]) -> str:
query = urlencode(params)
return f"{api_base.rstrip('/')}{path}?{query}"
def fetch_json(
api_base: str,
path: str,
params: dict[str, object],
timeout: float,
) -> object:
url = _request_url(api_base, path, params)
request = Request(url, headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout) as response:
payload = response.read().decode("utf-8")
except HTTPError as exc:
return _http_error_payload(exc)
except URLError as exc:
raise AnalysisTxtError(f"无法连接 FastAPI{url},原因:{exc.reason}") from exc
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise AnalysisTxtError(f"接口返回的内容不是 JSON{url}") from exc
def _http_error_payload(exc: HTTPError) -> dict[str, object]:
body = exc.read().decode("utf-8", errors="replace")
try:
response_content: object = json.loads(body)
except json.JSONDecodeError:
response_content = body
return {
"HTTP状态码": exc.code,
"错误信息": f"接口返回 HTTP {exc.code}",
"响应内容": response_content,
}
def render_task(task: AnalysisTask, results: Sequence[object]) -> str:
lines = [task.prompt, ""]
for call, result in zip(task.calls, results, strict=True):
lines.extend(
[
f"调用接口GET {call.path} 记录数据结果",
"请求参数:",
json.dumps(call.params, ensure_ascii=False, indent=2),
"数据结果:",
json.dumps(result, ensure_ascii=False, indent=2),
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def render_unified_txt(task_results: Sequence[tuple[AnalysisTask, Sequence[object]]]) -> str:
return "\n\n".join(
render_task(task, results).rstrip()
for task, results in task_results
) + "\n"
def default_output_filename(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> str:
filename_parts = [
str(year),
str(month),
_safe_filename_segment(scope),
]
if scope_value is not None and scope_value.strip():
filename_parts.append(_safe_filename_segment(scope_value))
return f"{'_'.join(filename_parts)}数据分析结果.txt"
def default_output_path(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> Path:
return Path("outputs") / default_output_filename(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
def default_output_dir(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> Path:
return default_output_path(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
).parent
def generate_files(
*,
api_base: str,
year: int,
month: int,
ct: str,
scope: str,
scope_value: str | None,
timeout: float,
output_path: Path | None = None,
output_dir: Path | None = None,
fetcher: Fetcher = fetch_json,
) -> list[Path]:
tasks = build_analysis_tasks(
year=year,
month=month,
ct=ct,
scope=scope,
scope_value=scope_value,
)
if output_path is None:
if output_dir is None:
output_path = default_output_path(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
else:
output_path = output_dir / default_output_filename(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
task_results: list[tuple[AnalysisTask, Sequence[object]]] = []
for task in tasks:
results = [
fetcher(api_base, call.path, call.params, timeout)
for call in task.calls
]
task_results.append((task, results))
output_path.write_text(render_unified_txt(task_results), encoding="utf-8")
written_files = [output_path]
return written_files
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="调用 marketAPI 并生成数据分析基础 txt 文件。",
)
parser.add_argument("--api-base", help="覆盖脚本顶部的 API_BASE。")
parser.add_argument("--output-path", type=Path, help="覆盖脚本顶部的 OUTPUT_PATH。")
parser.add_argument(
"--output-dir",
type=Path,
help="自定义输出目录,文件名按规则生成。",
)
parser.add_argument("--year", type=int, help="覆盖脚本顶部的 YEAR。")
parser.add_argument("--month", type=int, help="覆盖脚本顶部的 MONTH。")
parser.add_argument("--ct", help="覆盖脚本顶部的 CT。")
parser.add_argument("--scope", help="覆盖脚本顶部的 SCOPE。")
parser.add_argument("--scope-value", help="覆盖脚本顶部的 SCOPE_VALUE。")
parser.add_argument(
"--timeout",
type=float,
help="覆盖脚本顶部的 TIMEOUT。",
)
return parser.parse_args(argv)
def _output_path_from_config(
output_path: str | Path | None,
output_dir: str | Path | None,
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> Path:
if output_path is not None and str(output_path).strip():
return Path(output_path)
if output_dir is not None and str(output_dir).strip():
return Path(output_dir) / default_output_filename(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
return default_output_path(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
def resolve_run_config(args: argparse.Namespace) -> RunConfig:
year = args.year if args.year is not None else YEAR
month = _normalize_month(args.month if args.month is not None else MONTH)
ct = _normalize_ct(args.ct if args.ct is not None else CT)
scope_input = args.scope if args.scope is not None else SCOPE
scope_value_input = args.scope_value if args.scope_value is not None else SCOPE_VALUE
output_path_input = args.output_path if args.output_path is not None else OUTPUT_PATH
output_dir_input = args.output_dir if args.output_dir is not None else OUTPUT_DIR
timeout = args.timeout if args.timeout is not None else TIMEOUT
api_base = args.api_base if args.api_base is not None else API_BASE
scope = scope_input.strip()
scope, scope_value = _normalize_scope(scope, scope_value_input)
output_path = _output_path_from_config(
output_path_input,
output_dir_input,
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
return RunConfig(
api_base=api_base,
output_path=output_path,
year=year,
month=month,
ct=ct,
scope=scope,
scope_value=scope_value,
timeout=timeout,
)
def main(argv: Sequence[str] | None = None) -> int:
config = resolve_run_config(parse_args(argv))
written_files = generate_files(
api_base=config.api_base,
output_path=config.output_path,
year=config.year,
month=config.month,
ct=config.ct,
scope=config.scope,
scope_value=config.scope_value,
timeout=config.timeout,
)
print(f"已生成 {len(written_files)} 个 txt 文件:")
for path in written_files:
print(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())