uspto-oa-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oa_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """odp-oa-cli: USPTO 특허 심사과정 분석 CLI."""
2
+
3
+ __version__ = "0.1.0"
oa_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from oa_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
oa_cli/cli.py ADDED
@@ -0,0 +1,180 @@
1
+ """USPTO 특허 심사과정 분석 CLI."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from oa_cli import __version__
13
+ from oa_cli import config as cfg
14
+
15
+ console = Console()
16
+
17
+
18
+ @click.group(invoke_without_command=True)
19
+ @click.version_option(__version__, prog_name="uspto-oa")
20
+ @click.option("--verbose", "-v", is_flag=True, default=False, help="상세 로그 출력.")
21
+ @click.pass_context
22
+ def main(ctx: click.Context, verbose: bool) -> None:
23
+ """USPTO 특허 심사과정 분석 CLI.
24
+
25
+ \b
26
+ 빠른 시작:
27
+ uspto-oa configure # API 키 저장
28
+ uspto-oa download 16330077 # 문서 다운로드
29
+ uspto-oa extract 16330077 # XML 파싱 → MD 생성
30
+ """
31
+ ctx.ensure_object(dict)
32
+ ctx.obj["verbose"] = verbose
33
+ level = logging.DEBUG if verbose else logging.WARNING
34
+ logging.basicConfig(stream=sys.stderr, level=level, format="%(levelname)s %(message)s")
35
+ if ctx.invoked_subcommand is None:
36
+ click.echo(f"uspto-oa v{__version__}")
37
+ click.echo(ctx.get_help())
38
+
39
+
40
+ # ── configure ────────────────────────────────────────────────────────────────
41
+
42
+ @main.command()
43
+ @click.option("--show", is_flag=True, default=False, help="현재 설정만 표시하고 종료.")
44
+ def configure(show: bool) -> None:
45
+ """API 키와 기본 설정을 저장합니다.
46
+
47
+ \b
48
+ 설정 파일: ~/.oa-cli.toml
49
+ Enter 입력 시 기존 값 유지.
50
+ """
51
+ existing = cfg.load()
52
+ auth_cfg = existing.get("auth", {})
53
+
54
+ click.echo("OA CLI — 설정")
55
+ click.echo("─" * 40)
56
+ click.echo(f"설정 파일: {cfg.CONFIG_PATH}")
57
+ click.echo()
58
+
59
+ current_key = auth_cfg.get("api_key", "")
60
+
61
+ if show:
62
+ click.echo(f" auth.api_key = {cfg.mask_key(current_key)}")
63
+ return
64
+
65
+ new_key = click.prompt(
66
+ f"USPTO API 키 [{cfg.mask_key(current_key)}]",
67
+ default="",
68
+ show_default=False,
69
+ ).strip()
70
+
71
+ final_key = new_key if new_key else current_key
72
+ if not final_key:
73
+ click.echo("\n경고: API 키가 없습니다. 나중에 다시 실행하세요.", err=True)
74
+ return
75
+
76
+ cfg.save({"auth": {"api_key": final_key}})
77
+ click.echo(f"\n설정 저장: {cfg.CONFIG_PATH}")
78
+ click.echo(f" auth.api_key = {cfg.mask_key(final_key)}")
79
+
80
+
81
+ # ── download ─────────────────────────────────────────────────────────────────
82
+
83
+ @main.command()
84
+ @click.argument("application")
85
+ @click.option("--doc-codes", default=None, metavar="CODES",
86
+ help="쉼표 구분 문서 코드 (예: CTNF,CTFR,NOA). 생략 시 전체 수집 대상.")
87
+ @click.option("--output-dir", default=None, metavar="DIR", help="저장 경로 (기본: file/{app_num}/).")
88
+ @click.option("--force", is_flag=True, default=False, help="기존 파일도 재다운로드.")
89
+ @click.option("--api-key", default=None, help="API 키 (설정 파일·환경변수보다 우선).")
90
+ @click.pass_context
91
+ def download(
92
+ ctx: click.Context,
93
+ application: str,
94
+ doc_codes: Optional[str],
95
+ output_dir: Optional[str],
96
+ force: bool,
97
+ api_key: Optional[str],
98
+ ) -> None:
99
+ """심사과정 문서를 다운로드합니다 (XML 우선, PDF 폴백).
100
+
101
+ \b
102
+ 예시:
103
+ oa download 16330077
104
+ oa download US12228644B2
105
+ oa download 16330077 --doc-codes CTNF,CTFR,NOA
106
+ oa download 16330077 --force
107
+ """
108
+ from oa_cli.prosecution.collect import download_docs, normalize_app_number, resolve_patent_number
109
+
110
+ key = cfg.require_api_key(api_key)
111
+ raw = application
112
+
113
+ if raw.upper().startswith("US") and any(c.isalpha() for c in raw[2:]):
114
+ console.print(f"[dim]특허번호 '{raw}' → 출원번호 조회 중...[/dim]")
115
+ app_num = resolve_patent_number(raw, key)
116
+ console.print(f"[dim]출원번호: {app_num}[/dim]")
117
+ else:
118
+ app_num = normalize_app_number(raw)
119
+
120
+ out_dir = output_dir or str(Path.cwd() / "file" / app_num)
121
+ codes = doc_codes.split(",") if doc_codes else None
122
+
123
+ console.print(f"[bold]출원번호[/] {app_num} → [bold]{out_dir}[/]")
124
+ console.print()
125
+
126
+ results = download_docs(
127
+ app_number=app_num,
128
+ api_key=key,
129
+ doc_codes=codes,
130
+ output_dir=out_dir,
131
+ force=force,
132
+ )
133
+
134
+ if not results:
135
+ console.print("[yellow]다운로드된 문서가 없습니다.[/yellow]")
136
+ return
137
+
138
+ table = Table(title=f"다운로드 결과 — {app_num}", show_lines=False)
139
+ table.add_column("날짜", style="cyan", no_wrap=True)
140
+ table.add_column("코드", style="magenta")
141
+ table.add_column("형식", justify="center")
142
+ table.add_column("파일명")
143
+ table.add_column("상태", justify="center")
144
+
145
+ downloaded = skipped = 0
146
+ for r in results:
147
+ status = "[dim]스킵[/dim]" if r["skipped"] else "[green]완료[/green]"
148
+ skipped += r["skipped"]
149
+ downloaded += not r["skipped"]
150
+ fmt = r.get("fmt", "pdf").upper()
151
+ fmt_display = f"[green]{fmt}[/green]" if fmt == "XML" else fmt
152
+ table.add_row(r["date"], r["code"], fmt_display, Path(r["path"]).name, status)
153
+
154
+ console.print(table)
155
+ console.print(f"\n[bold green]{downloaded}개 다운로드[/] / [dim]{skipped}개 스킵[/]")
156
+
157
+
158
+ # ── extract ──────────────────────────────────────────────────────────────────
159
+
160
+ @main.command()
161
+ @click.argument("application")
162
+ @click.option("--output-dir", default=None, metavar="DIR", help="파일 디렉토리 (기본: file/{app_num}/).")
163
+ def extract(application: str, output_dir: Optional[str]) -> None:
164
+ """다운로드된 XML 파일을 파싱해 prosecution.md 를 생성합니다.
165
+
166
+ \b
167
+ 예시:
168
+ oa extract 16330077
169
+ # 결과: file/16330077/16330077_prosecution.md
170
+ """
171
+ from oa_cli.prosecution.extract import extract as do_extract
172
+ from oa_cli.prosecution.collect import normalize_app_number
173
+
174
+ app_num = normalize_app_number(application)
175
+ file_dir = output_dir or str(Path.cwd() / "file" / app_num)
176
+ out_path = str(Path(file_dir) / f"{app_num}_prosecution.md")
177
+
178
+ console.print(f"[bold]출원번호[/] {app_num} → [bold]{out_path}[/]")
179
+ do_extract(app_num, file_dir=file_dir, output_path=out_path)
180
+ console.print(f"[bold green]완료[/] {out_path}")
oa_cli/config.py ADDED
@@ -0,0 +1,103 @@
1
+ """
2
+ 설정 파일 관리 및 API 키 우선순위 처리.
3
+
4
+ 우선순위 (높은 순):
5
+ 1. CLI --api-key 옵션 (일회성 override)
6
+ 2. USPTO_API_KEY 환경변수 (CI/CD, shell profile)
7
+ 3. ~/.oa-cli.toml [auth] api_key (oa configure 로 저장)
8
+
9
+ 개발 환경에서는 python-dotenv가 설치된 경우 .env를 자동 로드해
10
+ 환경변수(2번)로 처리된다. 배포 환경에서는 dotenv 없이도 정상 동작.
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import tomllib
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ CONFIG_PATH = Path.home() / ".oa-cli.toml"
20
+
21
+
22
+ def load() -> dict:
23
+ """~/.oa-cli.toml 을 읽어 dict 반환. 파일 없으면 빈 dict."""
24
+ if not CONFIG_PATH.exists():
25
+ return {}
26
+ with open(CONFIG_PATH, "rb") as f:
27
+ return tomllib.load(f)
28
+
29
+
30
+ def save(config: dict) -> None:
31
+ """config dict 를 ~/.oa-cli.toml 에 저장."""
32
+ CONFIG_PATH.write_text(_dump_toml(config), encoding="utf-8")
33
+
34
+
35
+ def resolve_api_key(cli_key: Optional[str] = None) -> str:
36
+ """API 키를 우선순위에 따라 결정해 반환. 없으면 빈 문자열."""
37
+ if cli_key:
38
+ return cli_key
39
+
40
+ _try_load_dotenv()
41
+ env_key = os.getenv("USPTO_API_KEY", "")
42
+ if env_key:
43
+ return env_key
44
+
45
+ cfg = load()
46
+ return cfg.get("auth", {}).get("api_key", "")
47
+
48
+
49
+ def require_api_key(cli_key: Optional[str] = None) -> str:
50
+ """API 키를 반환. 없으면 오류 메시지 출력 후 종료."""
51
+ key = resolve_api_key(cli_key)
52
+ if not key:
53
+ import click
54
+ click.echo(
55
+ "오류: USPTO API 키가 설정되지 않았습니다.\n"
56
+ "\n"
57
+ "다음 중 하나로 설정하세요:\n"
58
+ " uspto-oa configure # 설정 파일에 저장 (권장)\n"
59
+ " export USPTO_API_KEY=.. # 환경변수\n"
60
+ " uspto-oa download --api-key KEY <app_number> # 일회성 옵션",
61
+ err=True,
62
+ )
63
+ sys.exit(1)
64
+ return key
65
+
66
+
67
+ def mask_key(key: str) -> str:
68
+ """API 키를 마스킹하여 반환 (끝 4자리만 표시)."""
69
+ if not key:
70
+ return "(없음)"
71
+ if len(key) <= 4:
72
+ return "****"
73
+ return "****" + key[-4:]
74
+
75
+
76
+ def _try_load_dotenv() -> None:
77
+ """python-dotenv 가 설치된 경우(개발 환경)에만 .env 를 로드."""
78
+ try:
79
+ from dotenv import load_dotenv
80
+ load_dotenv()
81
+ except ImportError:
82
+ pass
83
+
84
+
85
+ def _dump_toml(config: dict) -> str:
86
+ """config dict 를 TOML 문자열로 직렬화 (tomllib 은 read-only 라 직접 구현)."""
87
+ lines: list[str] = []
88
+ for section, values in config.items():
89
+ if not isinstance(values, dict):
90
+ continue
91
+ lines.append(f"[{section}]")
92
+ for key, val in values.items():
93
+ if val is None or val == "":
94
+ continue
95
+ if isinstance(val, bool):
96
+ lines.append(f"{key} = {'true' if val else 'false'}")
97
+ elif isinstance(val, (int, float)):
98
+ lines.append(f"{key} = {val}")
99
+ else:
100
+ escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
101
+ lines.append(f'{key} = "{escaped}"')
102
+ lines.append("")
103
+ return "\n".join(lines)
File without changes
@@ -0,0 +1,198 @@
1
+ """출원 문서 목록 필터링 및 일괄 다운로드 (XML 우선, PDF 폴백)."""
2
+
3
+ import io
4
+ import logging
5
+ import tarfile
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from oa_cli.uspto.client import make_api_request
10
+ from oa_cli.uspto.download import download_file, download_bytes
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ BASE_URL = "https://api.uspto.gov/api/v1/patent"
15
+ DOWNLOAD_BASE = "https://api.uspto.gov"
16
+
17
+ # 심사 과정 분석에 필요한 문서 코드 (정확 일치)
18
+ PROSECUTION_CODES_EXACT = {
19
+ "CTNF", # Non-Final Rejection
20
+ "CTFR", # Final Rejection
21
+ "REM", # Remarks
22
+ "NACT", # Notice of Allowance (구형식)
23
+ "NOA", # Notice of Allowance (신형식)
24
+ "ABN", # Abandonment
25
+ "SRNT", # Search Report (National)
26
+ "SRFW", # Search Report (Foreign)
27
+ "N271", # 271 Notice
28
+ "EXIN", # Examiner Interview
29
+ "RCE", # Request for Continued Examination (구형식)
30
+ "RCEX", # Request for Continued Examination (신형식)
31
+ "CTAV", # Advisory Action
32
+ "892", # Prior Art reference (Examiner)
33
+ "1449", # Information Disclosure Statement
34
+ "IDS", # Information Disclosure Statement
35
+ }
36
+
37
+ # 이 접두사로 시작하는 코드는 모두 포함 (Amendment 변형들)
38
+ # 실제 응답 예: A, A..., A.NE, A.NE.AFCP, A.NE.AFCP.D, A.PE, AMSB, ANE.I
39
+ PROSECUTION_CODE_PREFIXES = ("A",)
40
+
41
+
42
+ def normalize_app_number(raw: str) -> str:
43
+ """출원번호 정규화 — 하이픈·슬래시·공백 제거."""
44
+ return raw.replace("/", "").replace("-", "").replace(" ", "")
45
+
46
+
47
+ def resolve_patent_number(patent_number: str, api_key: str) -> str:
48
+ """특허번호(예: US12228644B2) → 출원번호 변환."""
49
+ digits = "".join(filter(str.isdigit, patent_number))
50
+ data = make_api_request(f"{BASE_URL}/applications", api_key, params={"patentNumber": digits})
51
+ bag = data.get("patentApplicationNumberBag") or data.get("results") or []
52
+ if bag:
53
+ first = bag[0]
54
+ raw = first.get("patentApplicationNumber") or first.get("applicationNumber", "") \
55
+ if isinstance(first, dict) else str(first)
56
+ return normalize_app_number(raw)
57
+ raise ValueError(f"특허번호 '{patent_number}'를 출원번호로 변환하지 못했습니다.")
58
+
59
+
60
+ def fetch_document_list(app_number: str, api_key: str) -> list[dict]:
61
+ """전체 문서 목록 조회 (서버사이드 필터 미사용 — 500 오류 유발)."""
62
+ url = f"{BASE_URL}/applications/{app_number}/documents"
63
+ data = make_api_request(url, api_key)
64
+ return (
65
+ data.get("documentBag")
66
+ or data.get("patentDocumentFiles")
67
+ or data.get("documents")
68
+ or []
69
+ )
70
+
71
+
72
+ MIME_PRIORITY = ["XML", "PDF"] # MS_WORD 제외
73
+
74
+
75
+ def _is_prosecution_doc(code: str, allowed: set[str] | None) -> bool:
76
+ if allowed is not None:
77
+ return code in allowed
78
+ if code in PROSECUTION_CODES_EXACT:
79
+ return True
80
+ return any(code.startswith(p) for p in PROSECUTION_CODE_PREFIXES)
81
+
82
+
83
+ def _best_option(doc: dict, app_number: str) -> tuple[str, str] | None:
84
+ """우선순위(XML > PDF)에 따라 (url, ext) 반환."""
85
+ options: dict[str, str] = {}
86
+ for opt in doc.get("downloadOptionBag") or []:
87
+ mime = (opt.get("mimeTypeIdentifier") or "").upper()
88
+ url = opt.get("downloadUrl") or opt.get("url") or ""
89
+ if mime in MIME_PRIORITY and url:
90
+ options[mime] = url if url.startswith("http") else DOWNLOAD_BASE + url
91
+
92
+ for mime in MIME_PRIORITY:
93
+ if mime in options:
94
+ ext = mime.lower()
95
+ return options[mime], ext
96
+
97
+ doc_id = doc.get("documentIdentifier")
98
+ if doc_id:
99
+ return f"{DOWNLOAD_BASE}/api/v1/download/applications/{app_number}/{doc_id}.pdf", "pdf"
100
+
101
+ return None
102
+
103
+
104
+ def _pdf_fallback_url(doc: dict, app_number: str) -> Optional[str]:
105
+ """PDF 다운로드 URL만 추출 (XML 실패 시 폴백용)."""
106
+ for opt in doc.get("downloadOptionBag") or []:
107
+ if (opt.get("mimeTypeIdentifier") or "").upper() == "PDF":
108
+ url = opt.get("downloadUrl") or opt.get("url") or ""
109
+ if url:
110
+ return url if url.startswith("http") else DOWNLOAD_BASE + url
111
+ doc_id = doc.get("documentIdentifier")
112
+ if doc_id:
113
+ return f"{DOWNLOAD_BASE}/api/v1/download/applications/{app_number}/{doc_id}.pdf"
114
+ return None
115
+
116
+
117
+ def _save_xml_from_tar(raw: bytes, save_path: str):
118
+ """tar 아카이브에서 첫 번째 .xml 파일을 꺼내 save_path에 저장."""
119
+ with tarfile.open(fileobj=io.BytesIO(raw)) as tf:
120
+ for member in tf.getmembers():
121
+ if member.name.endswith(".xml"):
122
+ f = tf.extractfile(member)
123
+ if f:
124
+ Path(save_path).write_bytes(f.read())
125
+ return
126
+ raise ValueError("tar 아카이브에 .xml 파일이 없습니다.")
127
+
128
+
129
+ def download_docs(
130
+ app_number: str,
131
+ api_key: str,
132
+ doc_codes: Optional[list[str]] = None,
133
+ output_dir: Optional[str] = None,
134
+ force: bool = False,
135
+ ) -> list[dict]:
136
+ """
137
+ 심사 관련 문서를 XML 우선(없으면 PDF)으로 다운로드하고 메타데이터 목록을 반환.
138
+
139
+ Returns:
140
+ [{"code": str, "date": str, "doc_id": str, "fmt": str, "path": str, "skipped": bool}, ...]
141
+ """
142
+ allowed = set(doc_codes) if doc_codes else None
143
+ if output_dir is None:
144
+ output_dir = str(Path.cwd() / "file" / app_number)
145
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
146
+
147
+ all_docs = fetch_document_list(app_number, api_key)
148
+ if not all_docs:
149
+ logger.warning(f"{app_number}: 문서 없음")
150
+ return []
151
+
152
+ docs = [d for d in all_docs
153
+ if _is_prosecution_doc(d.get("documentCode") or d.get("code") or "", allowed)]
154
+ logger.info(f"전체 {len(all_docs)}개 중 심사 관련 {len(docs)}개")
155
+
156
+ results = []
157
+ for doc in docs:
158
+ code = doc.get("documentCode") or doc.get("code") or "UNKNOWN"
159
+ date = (doc.get("officialDate") or doc.get("mailDate") or "").split("T")[0]
160
+ doc_id = doc.get("documentIdentifier") or doc.get("documentId") or ""
161
+
162
+ option = _best_option(doc, app_number)
163
+ if not option:
164
+ logger.warning(f" {code} ({date}): 다운로드 URL 없음")
165
+ continue
166
+ url, ext = option
167
+
168
+ filename = f"{date}_{code}_{doc_id}.{ext}".replace(" ", "_")
169
+ save_path = str(Path(output_dir) / filename)
170
+ entry = {"code": code, "date": date, "doc_id": doc_id, "fmt": ext, "path": save_path, "skipped": False}
171
+
172
+ if not force and Path(save_path).exists():
173
+ entry["skipped"] = True
174
+ results.append(entry)
175
+ continue
176
+
177
+ try:
178
+ if ext == "xml":
179
+ try:
180
+ raw = download_bytes(url, api_key)
181
+ _save_xml_from_tar(raw, save_path)
182
+ except (ValueError, Exception) as xml_err:
183
+ logger.warning(f" {code} ({date}) XML 실패({xml_err}), PDF로 폴백")
184
+ pdf_url = _pdf_fallback_url(doc, app_number)
185
+ if not pdf_url:
186
+ logger.error(f" {code} ({date}) PDF 폴백 URL도 없음")
187
+ continue
188
+ save_path = save_path.removesuffix(".xml") + ".pdf"
189
+ entry["fmt"] = "pdf"
190
+ entry["path"] = save_path
191
+ download_file(pdf_url, api_key, save_path)
192
+ else:
193
+ download_file(url, api_key, save_path)
194
+ results.append(entry)
195
+ except Exception as e:
196
+ logger.error(f" {code} ({date}) 다운로드 실패: {e}")
197
+
198
+ return results
@@ -0,0 +1,224 @@
1
+ """다운로드된 XML 파일 파싱 → 심사과정 분석 MD 생성."""
2
+
3
+ import re
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import xml.etree.ElementTree as ET
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ USCOM = "{urn:us:gov:doc:uspto:common}"
12
+
13
+ CODE_LABELS = {
14
+ "CTNF": "Non-Final Rejection",
15
+ "CTFR": "Final Rejection",
16
+ "NOA": "Notice of Allowance",
17
+ "NACT": "Notice of Allowance",
18
+ "IDS": "Information Disclosure Statement",
19
+ "REM": "Remarks",
20
+ "ABST": "Abstract",
21
+ "CLM": "Claims",
22
+ "SPEC": "Specification",
23
+ }
24
+
25
+ # FormParagraph 번호 분류
26
+ _FP_REJECTION = "07-21-aia" # 거절 항목 (CTNF/CTFR)
27
+ _FP_FINAL_FLAG = "07-39" # Final Action 선언
28
+ _FP_ALLOWED_CLAIMS = "12-151-07" # 허여 청구항
29
+ _FP_ALLOW_REASON = "13-03" # 허여 이유 (Examiner's Statement)
30
+
31
+
32
+ # ── 텍스트 정규화 ────────────────────────────────────────────────
33
+
34
+ def _fp_text(fp_el) -> str:
35
+ """FormParagraph 요소의 텍스트를 정규화해서 반환."""
36
+ raw = " ".join(fp_el.itertext())
37
+ text = re.sub(r"\s+", " ", raw).strip()
38
+ text = re.sub(r"^[\w\.\-]+ (?:AIA )?", "", text, count=1)
39
+ return text
40
+
41
+
42
+ def _all_text(root) -> str:
43
+ """루트 하위 전체 텍스트 추출 및 정규화."""
44
+ return re.sub(r"\s+", " ", " ".join(root.itertext())).strip()
45
+
46
+
47
+ # ── 문서 타입별 파서 ─────────────────────────────────────────────
48
+
49
+ def _fp_iter(root, number: str):
50
+ for fp in root.iter(f"{USCOM}FormParagraph"):
51
+ num_el = fp.find(f"{USCOM}FormParagraphNumber")
52
+ if num_el is not None and (num_el.text or "").strip() == number:
53
+ yield fp
54
+
55
+
56
+ def _parse_oa(root) -> dict:
57
+ """CTNF / CTFR: 거절 항목 파싱."""
58
+ rejections = [_fp_text(fp) for fp in _fp_iter(root, _FP_REJECTION)]
59
+ is_final = any(True for _ in _fp_iter(root, _FP_FINAL_FLAG))
60
+ return {
61
+ "is_final": is_final,
62
+ "rejections": rejections,
63
+ }
64
+
65
+
66
+ def _parse_noa(root) -> dict:
67
+ """NOA: 허여 청구항 + 허여 이유 파싱."""
68
+ allowed = next((_fp_text(fp) for fp in _fp_iter(root, _FP_ALLOWED_CLAIMS)), "")
69
+ reason = next((_fp_text(fp) for fp in _fp_iter(root, _FP_ALLOW_REASON)), "")
70
+ return {
71
+ "allowed_claims": allowed,
72
+ "reason": reason,
73
+ }
74
+
75
+
76
+ # ── 파일 단위 파서 ────────────────────────────────────────────────
77
+
78
+ def parse_xml_file(xml_path: Path) -> Optional[dict]:
79
+ """
80
+ XML 파일 1개를 파싱해 구조화된 dict로 반환.
81
+
82
+ Returns None if the file is not a parseable prosecution document.
83
+ """
84
+ stem_parts = xml_path.stem.split("_", 2)
85
+ if len(stem_parts) < 2:
86
+ return None
87
+ date, code = stem_parts[0], stem_parts[1]
88
+ doc_id = stem_parts[2] if len(stem_parts) > 2 else ""
89
+
90
+ try:
91
+ tree = ET.parse(xml_path)
92
+ root = tree.getroot()
93
+ except ET.ParseError as e:
94
+ logger.warning(f"XML 파싱 실패 {xml_path.name}: {e}")
95
+ return None
96
+
97
+ base = {
98
+ "date": date,
99
+ "code": code,
100
+ "doc_id": doc_id,
101
+ "label": CODE_LABELS.get(code, code),
102
+ "path": str(xml_path),
103
+ "fmt": "xml",
104
+ }
105
+
106
+ if code in ("CTNF", "CTFR"):
107
+ return {**base, **_parse_oa(root)}
108
+ if code in ("NOA", "NACT"):
109
+ return {**base, **_parse_noa(root)}
110
+
111
+ return None
112
+
113
+
114
+ # ── 타임라인 조합 ────────────────────────────────────────────────
115
+
116
+ def build_timeline(app_number: str, file_dir: Optional[str] = None) -> list[dict]:
117
+ """
118
+ file_dir 내 모든 파일(.xml / .pdf)을 날짜순으로 정리해 타임라인 리스트를 반환.
119
+ XML 파싱 가능 문서는 파싱 결과를 포함하고, PDF는 경로 참조만 포함한다.
120
+ """
121
+ if file_dir is None:
122
+ file_dir = str(Path.cwd() / "file" / app_number)
123
+
124
+ base_dir = Path(file_dir)
125
+ entries: dict[str, dict] = {}
126
+
127
+ for f in sorted(base_dir.glob("*.pdf")):
128
+ stem_parts = f.stem.split("_", 2)
129
+ if len(stem_parts) < 2:
130
+ continue
131
+ date, code = stem_parts[0], stem_parts[1]
132
+ doc_id = stem_parts[2] if len(stem_parts) > 2 else ""
133
+ key = f"{date}_{code}_{doc_id}"
134
+ entries[key] = {
135
+ "date": date, "code": code, "doc_id": doc_id,
136
+ "label": CODE_LABELS.get(code, code),
137
+ "path": str(f), "fmt": "pdf",
138
+ }
139
+
140
+ for f in sorted(base_dir.glob("*.xml")):
141
+ stem_parts = f.stem.split("_", 2)
142
+ if len(stem_parts) < 2:
143
+ continue
144
+ date, code = stem_parts[0], stem_parts[1]
145
+ doc_id = stem_parts[2] if len(stem_parts) > 2 else ""
146
+ key = f"{date}_{code}_{doc_id}"
147
+ parsed = parse_xml_file(f)
148
+ if parsed:
149
+ entries[key] = parsed
150
+ else:
151
+ entries[key] = {
152
+ "date": date, "code": code, "doc_id": doc_id,
153
+ "label": CODE_LABELS.get(code, code),
154
+ "path": str(f), "fmt": "xml",
155
+ }
156
+
157
+ return sorted(entries.values(), key=lambda x: x["date"])
158
+
159
+
160
+ # ── MD 렌더러 ─────────────────────────────────────────────────────
161
+
162
+ def render_md(app_number: str, timeline: list[dict]) -> str:
163
+ lines: list[str] = []
164
+
165
+ lines += [f"# 심사과정 분석 — {app_number}", ""]
166
+
167
+ lines += ["## 타임라인", ""]
168
+ lines += ["| 날짜 | 문서 | 형식 | 파일 |"]
169
+ lines += ["|------|------|:----:|------|"]
170
+ for e in timeline:
171
+ lines.append(
172
+ f"| {e['date']} | {e['label']} (`{e['code']}`) "
173
+ f"| {e['fmt'].upper()} | `{Path(e['path']).name}` |"
174
+ )
175
+ lines += [""]
176
+
177
+ oa_docs = [e for e in timeline if e["code"] in ("CTNF", "CTFR") and "rejections" in e]
178
+ if oa_docs:
179
+ lines += ["---", "## Office Action 상세", ""]
180
+ for doc in oa_docs:
181
+ oa_type = "Final Rejection" if doc.get("is_final") else "Non-Final Rejection"
182
+ lines += [f"### {oa_type} — {doc['date']}", ""]
183
+ for i, rej in enumerate(doc["rejections"], 1):
184
+ lines += [f"**거절 {i}**", "", rej, ""]
185
+
186
+ noa_docs = [e for e in timeline if e["code"] in ("NOA", "NACT") and "allowed_claims" in e]
187
+ if noa_docs:
188
+ lines += ["---", "## Notice of Allowance 상세", ""]
189
+ for doc in noa_docs:
190
+ lines += [f"### {doc['date']}", ""]
191
+ if doc.get("allowed_claims"):
192
+ lines += ["**허여 청구항**", "", doc["allowed_claims"], ""]
193
+ if doc.get("reason"):
194
+ lines += ["**허여 이유 (Examiner's Statement)**", "", doc["reason"], ""]
195
+
196
+ pdf_only = [e for e in timeline if e["fmt"] == "pdf" and "rejections" not in e and "allowed_claims" not in e]
197
+ if pdf_only:
198
+ lines += ["---", "## PDF 전용 문서 (텍스트 추출 불가)", ""]
199
+ lines += ["아래 파일은 이미지 PDF로 구성되어 자동 파싱이 불가합니다. AI 에이전트에 직접 전달하세요.", ""]
200
+ lines += ["| 날짜 | 문서 | 파일 |"]
201
+ lines += ["|------|------|------|"]
202
+ for e in pdf_only:
203
+ lines.append(f"| {e['date']} | {e['label']} (`{e['code']}`) | `{Path(e['path']).name}` |")
204
+ lines += [""]
205
+
206
+ return "\n".join(lines)
207
+
208
+
209
+ # ── 진입점 ────────────────────────────────────────────────────────
210
+
211
+ def extract(
212
+ app_number: str,
213
+ file_dir: Optional[str] = None,
214
+ output_path: Optional[str] = None,
215
+ ) -> str:
216
+ """다운로드된 파일을 파싱해 MD 문자열을 반환하고, output_path가 지정되면 파일로 저장."""
217
+ timeline = build_timeline(app_number, file_dir)
218
+ md = render_md(app_number, timeline)
219
+
220
+ if output_path:
221
+ Path(output_path).write_text(md, encoding="utf-8")
222
+ logger.info(f"저장 완료: {output_path}")
223
+
224
+ return md
File without changes
oa_cli/uspto/client.py ADDED
@@ -0,0 +1,40 @@
1
+ """USPTO ODP API HTTP 클라이언트 (재시도/백오프 포함)."""
2
+
3
+ import time
4
+ import logging
5
+ import requests
6
+ from typing import Any, Dict, Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def make_api_request(
12
+ url: str,
13
+ api_key: str,
14
+ params: Optional[Dict[str, Any]] = None,
15
+ timeout: int = 30,
16
+ retries: int = 3,
17
+ backoff_factor: float = 1.0,
18
+ ) -> Dict[str, Any]:
19
+ headers = {
20
+ "X-API-KEY": api_key,
21
+ "accept": "application/json",
22
+ }
23
+ for i in range(retries):
24
+ try:
25
+ logger.debug(f"GET {url} params={params}")
26
+ resp = requests.get(url, headers=headers, params=params, timeout=timeout)
27
+ resp.raise_for_status()
28
+ return resp.json()
29
+ except requests.HTTPError as e:
30
+ if e.response.status_code in (429, 500, 502, 503, 504) and i < retries - 1:
31
+ wait = backoff_factor * (2 ** i)
32
+ logger.warning(f"HTTP {e.response.status_code}, {wait:.0f}초 후 재시도...")
33
+ time.sleep(wait)
34
+ else:
35
+ logger.error(f"API 요청 실패: {e}")
36
+ raise
37
+ except requests.RequestException as e:
38
+ logger.error(f"API 요청 실패: {e}")
39
+ raise
40
+ raise RuntimeError("재시도 횟수 초과")
@@ -0,0 +1,20 @@
1
+ """출원 문서 목록 조회 엔드포인트 래퍼."""
2
+
3
+ import logging
4
+ from typing import Any, Dict
5
+
6
+ from oa_cli.uspto.client import make_api_request
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ BASE_URL = "https://api.uspto.gov/api/v1/patent"
11
+
12
+
13
+ def get_application_documents(
14
+ api_key: str,
15
+ application_number: str,
16
+ timeout: int = 30,
17
+ ) -> Dict[str, Any]:
18
+ """출원번호에 해당하는 전체 문서 목록을 반환."""
19
+ url = f"{BASE_URL}/applications/{application_number}/documents"
20
+ return make_api_request(url, api_key, timeout=timeout)
@@ -0,0 +1,80 @@
1
+ """파일 스트리밍 다운로드 유틸 (재시도/백오프 포함)."""
2
+
3
+ import time
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import requests
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def download_bytes(
14
+ url: str,
15
+ api_key: str,
16
+ retries: int = 5,
17
+ backoff_factor: float = 1.0,
18
+ timeout: int = 120,
19
+ ) -> bytes:
20
+ """URL에서 파일을 다운로드해 bytes로 반환 (tar 등 중간 처리가 필요한 경우)."""
21
+ headers = {"x-api-key": api_key}
22
+ for attempt in range(retries):
23
+ try:
24
+ resp = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
25
+ resp.raise_for_status()
26
+ return resp.content
27
+ except requests.HTTPError as e:
28
+ if e.response.status_code in (429, 500, 502, 503, 504) and attempt < retries - 1:
29
+ wait = backoff_factor * (2 ** attempt)
30
+ logger.warning(f"HTTP {e.response.status_code}, {wait:.0f}초 후 재시도...")
31
+ time.sleep(wait)
32
+ else:
33
+ raise
34
+ except requests.RequestException as e:
35
+ if attempt < retries - 1:
36
+ wait = backoff_factor * (2 ** attempt)
37
+ logger.warning(f"요청 오류 ({e}), {wait:.0f}초 후 재시도...")
38
+ time.sleep(wait)
39
+ else:
40
+ raise
41
+ raise RuntimeError(f"다운로드 재시도 횟수 초과: {url}")
42
+
43
+
44
+ def download_file(
45
+ url: str,
46
+ api_key: str,
47
+ save_path: str,
48
+ retries: int = 5,
49
+ backoff_factor: float = 1.0,
50
+ timeout: int = 120,
51
+ ) -> str:
52
+ """URL에서 파일을 다운로드해 save_path에 저장하고 경로를 반환."""
53
+ Path(save_path).parent.mkdir(parents=True, exist_ok=True)
54
+ headers = {"x-api-key": api_key}
55
+
56
+ for attempt in range(retries):
57
+ try:
58
+ resp = requests.get(url, headers=headers, stream=True, timeout=timeout, allow_redirects=True)
59
+ resp.raise_for_status()
60
+ with open(save_path, "wb") as f:
61
+ for chunk in resp.iter_content(chunk_size=8192):
62
+ f.write(chunk)
63
+ logger.debug(f"저장 완료: {save_path}")
64
+ return save_path
65
+ except requests.HTTPError as e:
66
+ if e.response.status_code in (429, 500, 502, 503, 504) and attempt < retries - 1:
67
+ wait = backoff_factor * (2 ** attempt)
68
+ logger.warning(f"HTTP {e.response.status_code}, {wait:.0f}초 후 재시도...")
69
+ time.sleep(wait)
70
+ else:
71
+ raise
72
+ except requests.RequestException as e:
73
+ if attempt < retries - 1:
74
+ wait = backoff_factor * (2 ** attempt)
75
+ logger.warning(f"요청 오류 ({e}), {wait:.0f}초 후 재시도...")
76
+ time.sleep(wait)
77
+ else:
78
+ raise
79
+
80
+ raise RuntimeError(f"다운로드 재시도 횟수 초과: {url}")
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: uspto-oa-cli
3
+ Version: 0.1.0
4
+ Summary: USPTO 특허 심사과정 분석 CLI — 문서 다운로드 · XML 파싱 · MD 생성
5
+ Project-URL: Homepage, https://github.com/noaa/uspto-oa-cli
6
+ License: MIT
7
+ Keywords: cli,office-action,patent,prosecution,uspto
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Legal Industry
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
16
+ Requires-Python: >=3.13
17
+ Requires-Dist: click>=8.0
18
+ Requires-Dist: pypdf>=6.11.0
19
+ Requires-Dist: requests>=2.34.2
20
+ Requires-Dist: rich>=15.0.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # odp-oa-cli
24
+
25
+ USPTO ODP(Open Data Portal) API를 통해 미국 특허 심사과정 문서를 다운로드하고, XML 파싱을 거쳐 구조화된 Markdown으로 변환하는 CLI 도구.
26
+
27
+ 생성된 MD 파일을 Claude Code, Gemini CLI 등 AI 에이전트에게 전달하여 심사 전략 분석을 수행하는 워크플로우를 지원한다.
28
+
29
+ ## 요구사항
30
+
31
+ - Python 3.13+
32
+ - [uv](https://docs.astral.sh/uv/)
33
+ - USPTO API 키 ([ODP 포털](https://developer.uspto.gov/)에서 발급)
34
+
35
+ ## 설치
36
+
37
+ ```bash
38
+ # 로컬 개발
39
+ uv sync
40
+
41
+ # PyPI에서 설치
42
+ pip install odp-oa-cli
43
+ ```
44
+
45
+ ## API 키 설정
46
+
47
+ ```bash
48
+ # 대화형 설정 (권장) — ~/.oa-cli.toml 에 저장
49
+ uspto-oa configure
50
+
51
+ # 현재 설정 확인
52
+ uspto-oa configure --show
53
+ ```
54
+
55
+ 또는 환경변수로 지정:
56
+
57
+ ```bash
58
+ export USPTO_API_KEY=your_api_key_here
59
+ ```
60
+
61
+ ## 사용법
62
+
63
+ ```bash
64
+ # 1. 문서 다운로드 (file/{app_num}/ 에 저장)
65
+ uspto-oa download 16330077
66
+
67
+ # 2. XML 파싱 → prosecution.md 생성
68
+ uspto-oa extract 16330077
69
+ # 결과: file/16330077/16330077_prosecution.md
70
+
71
+ # 특허번호 입력 (출원번호 자동 변환)
72
+ uspto-oa download US12228644B2
73
+
74
+ # 특정 문서 코드만 다운로드
75
+ uspto-oa download 16330077 --doc-codes CTNF,CTFR,NOA
76
+
77
+ # 강제 재다운로드 (기존 파일 덮어쓰기)
78
+ uspto-oa download 16330077 --force
79
+
80
+ # 상세 로그
81
+ uspto-oa -v download 16330077
82
+
83
+ # 일회성 API 키 지정
84
+ uspto-oa download 16330077 --api-key YOUR_KEY
85
+ ```
86
+
87
+ ### 커맨드 옵션
88
+
89
+ **`uspto-oa download <application>`**
90
+
91
+ | 옵션 | 설명 |
92
+ |------|------|
93
+ | `--doc-codes CODES` | 쉼표 구분 문서 코드 (예: `CTNF,CTFR,NOA`). 생략 시 전체 수집 대상 |
94
+ | `--output-dir DIR` | 저장 경로 (기본: `file/{app_num}/`) |
95
+ | `--force` | 기존 파일도 재다운로드 |
96
+ | `--api-key TEXT` | API 키 (설정 파일·환경변수보다 우선) |
97
+
98
+ **`uspto-oa extract <application>`**
99
+
100
+ | 옵션 | 설명 |
101
+ |------|------|
102
+ | `--output-dir DIR` | 파일 디렉토리 (기본: `file/{app_num}/`) |
103
+
104
+ ## 워크플로우
105
+
106
+ ```
107
+ uspto-oa download {app_num}
108
+ └─ file/{app_num}/ 에 XML / PDF 저장
109
+
110
+ uspto-oa extract {app_num}
111
+ └─ file/{app_num}/{app_num}_prosecution.md 생성
112
+ └─ AI 에이전트 (Claude Code / Gemini CLI)
113
+ └─ 심사 전략 분석, 요약, 질의응답
114
+ ```
115
+
116
+ ## 수집 대상 문서 코드
117
+
118
+ | 코드 | 의미 |
119
+ |------|------|
120
+ | `CTNF` | Non-Final Office Action |
121
+ | `CTFR` | Final Office Action |
122
+ | `NOA` / `NACT` | Notice of Allowance |
123
+ | `REM` | Remarks (의견서) |
124
+ | `ABN` | Abandonment |
125
+ | `SRNT` / `SRFW` | Search Report |
126
+ | `EXIN` | Examiner Interview |
127
+ | `RCE` / `RCEX` | Request for Continued Examination |
128
+ | `CTAV` | Advisory Action |
129
+ | `892` / `1449` / `IDS` | Prior Art / IDS |
130
+ | `A*` | Amendment 계열 전체 |
131
+
132
+ ## 생성 파일 구조
133
+
134
+ `file/{app_num}/{app_num}_prosecution.md`:
135
+
136
+ | 섹션 | 내용 |
137
+ |------|------|
138
+ | 타임라인 | 전체 문서 날짜순 표 (XML/PDF 형식 표시) |
139
+ | Office Action 상세 | CTNF/CTFR 거절 항목 전문 |
140
+ | Notice of Allowance 상세 | 허여 청구항 + Examiner's Statement |
141
+ | PDF 전용 문서 | Amendment 등 이미지 PDF 목록 (AI 에이전트 직접 전달용) |
142
+
143
+ ## PyPI 배포
144
+
145
+ ```bash
146
+ uv build
147
+ uv run twine upload dist/*
148
+ ```
@@ -0,0 +1,15 @@
1
+ oa_cli/__init__.py,sha256=Tz-z_JrYhmQMHHjU-Q97aHHHNHCXt6iiaWwclYeWj38,79
2
+ oa_cli/__main__.py,sha256=6MqQj8hojdWpAWJpV-K8aTvt68GacvVuUOsidQUKwoM,67
3
+ oa_cli/cli.py,sha256=EgRmGiYydzibqBAQCYa25HEsgbo_o3H5kR3KJ_zEXAE,6683
4
+ oa_cli/config.py,sha256=RK5L9uV9dhgoOToSiugGOtvft4iD-ef7_F7xYHej1Ng,3218
5
+ oa_cli/prosecution/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ oa_cli/prosecution/collect.py,sha256=KePpLRw4UI_FrP5TEOBu0rnMTNTSsn_jMRhXxKE1NTY,7645
7
+ oa_cli/prosecution/extract.py,sha256=MuAGrPjUpxRX9Ec5Irsf0NrtKczuO6gBssc31RY43_4,8332
8
+ oa_cli/uspto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ oa_cli/uspto/client.py,sha256=Ia4I03uTZtSC0olzBXsozeJDOKQBkXqjVZtj1Ftwb4M,1303
10
+ oa_cli/uspto/documents.py,sha256=_KGmav2TAb2Zy-udMAzgf-5Tp9WTblzmZZDYdL42OR8,563
11
+ oa_cli/uspto/download.py,sha256=7BIuNonUqyLiQuMEMqZKpn-RwSF2xRPB5vW1mX8CTa4,2943
12
+ uspto_oa_cli-0.1.0.dist-info/METADATA,sha256=MgN6wwKHkalVQ2VlqBIam338TYdnup3lfwpv6NgyY6I,4069
13
+ uspto_oa_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ uspto_oa_cli-0.1.0.dist-info/entry_points.txt,sha256=XOUMittc-2rOkgd1hPa4S0tZyJHSgoOy5QLX1HPDi9E,45
15
+ uspto_oa_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ uspto-oa = oa_cli.cli:main