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 +3 -0
- oa_cli/__main__.py +4 -0
- oa_cli/cli.py +180 -0
- oa_cli/config.py +103 -0
- oa_cli/prosecution/__init__.py +0 -0
- oa_cli/prosecution/collect.py +198 -0
- oa_cli/prosecution/extract.py +224 -0
- oa_cli/uspto/__init__.py +0 -0
- oa_cli/uspto/client.py +40 -0
- oa_cli/uspto/documents.py +20 -0
- oa_cli/uspto/download.py +80 -0
- uspto_oa_cli-0.1.0.dist-info/METADATA +148 -0
- uspto_oa_cli-0.1.0.dist-info/RECORD +15 -0
- uspto_oa_cli-0.1.0.dist-info/WHEEL +4 -0
- uspto_oa_cli-0.1.0.dist-info/entry_points.txt +2 -0
oa_cli/__init__.py
ADDED
oa_cli/__main__.py
ADDED
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
|
oa_cli/uspto/__init__.py
ADDED
|
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)
|
oa_cli/uspto/download.py
ADDED
|
@@ -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,,
|