Document Parse 동기 API는 요청당 최대 100페이지까지 지원하며, 비동기 API는 최대 2,000페이지까지 지원해요.
동기 API의 제한을 초과하는 문서의 경우, 두 가지 옵션이 있어요. 문서를 청크로 분할하여 동기 API를 호출하거나, 비동기 API를 사용하는 거예요. 워크플로우에 가장 적합한 방식을 선택해주세요.
아래 스크립트는 PDF 전체를 처음부터 끝까지 처리해요. UPSTAGE_API_KEY_INJECT를 본인의 API 키로, ./sample.pdf를 실제 파일 경로로 교체해주세요.
# pip install pypdf requestsimport requestsimport ioimport reimport jsonimport timefrom pypdf import PdfReader, PdfWriterMAX_RETRIES = 3CHUNK_SIZE = 100API_URL = "https://api.upstage.ai/v1/document-digitization"API_KEY = "UPSTAGE_API_KEY_INJECT"FILE_PATH = "./sample.pdf"REQUEST_OPTIONS = { "model": "document-parse", "output_formats": "['html', 'markdown', 'text']",}def parse_large_pdf(file_path, api_key): headers = {"Authorization": f"Bearer {api_key}"} reader = PdfReader(file_path) total_pages = len(reader.pages) html_parts, markdown_parts, text_parts = [], [], [] all_elements = [] api_value, model_value = "", "" total_usage_pages = 0 element_id_offset = 0 failed_chunks = [] for i in range(0, total_pages, CHUNK_SIZE): end_page = min(i + CHUNK_SIZE, total_pages) writer = PdfWriter() for page_num in range(i, end_page): writer.add_page(reader.pages[page_num]) res_json = _request_with_retry(writer, i, end_page, headers) if res_json is None: failed_chunks.append((i + 1, end_page)) continue if not api_value: api_value = res_json.get("api", "") if not model_value: model_value = res_json.get("model", "") chunk_elements = res_json.get("elements", []) # 정규식 기반 ID 재매핑: 단순한 숫자 ID를 가진 API 생성 HTML에 대해 동작합니다. # 출력 결과를 브라우저에서 렌더링한다면, 적절한 파서로 HTML을 # 새니타이즈하는 것을 고려하세요. for el in chunk_elements: old_id = el["id"] new_id = old_id + element_id_offset el["id"] = new_id el["page"] += i if "content" in el and "html" in el["content"]: el["content"]["html"] = re.sub( rf"\bid=['\"]{old_id}['\"]", f"id='{new_id}'", el["content"]["html"], ) all_elements.append(el) chunk_html = res_json.get("content", {}).get("html", "") if chunk_html: def update_html_id(match): return f"id='{int(match.group(1)) + element_id_offset}'" html_parts.append( re.sub(r"\bid=['\"](\d+)['\"]", update_html_id, chunk_html) ) chunk_md = res_json.get("content", {}).get("markdown", "") if chunk_md: markdown_parts.append(chunk_md) chunk_text = res_json.get("content", {}).get("text", "") if chunk_text: text_parts.append(chunk_text) total_usage_pages += res_json.get("usage", {}).get("pages", 0) element_id_offset += len(chunk_elements) if failed_chunks: print(f"Warning: {len(failed_chunks)} chunk(s) failed: {failed_chunks}") return { "api": api_value, "model": model_value, "content": { "html": "\n".join(html_parts), "markdown": "\n\n".join(markdown_parts), "text": "\n".join(text_parts), }, "elements": all_elements, "usage": {"pages": total_usage_pages}, }def _request_with_retry(writer, chunk_start, chunk_end, headers): with io.BytesIO() as pdf_bytes: writer.write(pdf_bytes) for attempt in range(MAX_RETRIES): pdf_bytes.seek(0) files = { "document": ( f"chunk_{chunk_start}.pdf", pdf_bytes, "application/pdf", ) } try: response = requests.post( API_URL, headers=headers, files=files, data=REQUEST_OPTIONS ) if response.status_code == 200: return response.json() elif response.status_code == 429: if attempt < MAX_RETRIES - 1: wait = 5 * (2**attempt) print( f"Rate limit hit. Waiting {wait}s... " f"({attempt + 1}/{MAX_RETRIES})" ) time.sleep(wait) elif 400 <= response.status_code < 500: raise ValueError( f"Client error ({response.status_code}): {response.text}" ) else: if attempt < MAX_RETRIES - 1: wait = 3 * (2**attempt) print( f"Server error ({response.status_code}). " f"Retrying in {wait}s... ({attempt + 1}/{MAX_RETRIES})" ) time.sleep(wait) except requests.exceptions.RequestException as e: if attempt < MAX_RETRIES - 1: wait = 3 * (2**attempt) print( f"Network error: {e}. Retrying in {wait}s... " f"({attempt + 1}/{MAX_RETRIES})" ) time.sleep(wait) print( f"Failed to process pages {chunk_start + 1}–{chunk_end}. Skipping chunk." ) return Noneif __name__ == "__main__": result = parse_large_pdf(FILE_PATH, API_KEY) with open("result.json", "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) print(f"Done. {result['usage']['pages']} pages processed. Saved to result.json")
이 스크립트는 한도 내에서 동작하도록 청크를 순차적으로(한 번에 하나씩) 전송합니다. Document Parse 동기 API는 RPS(초당 요청 수)와 PPM(분당 페이지 수) 한도를 모두 적용합니다. 여러 청크를 동시에 전송하면 이 한도를 쉽게 초과하여 429 Too Many Requests 오류가 발생할 수 있습니다. 429 오류가 발생하면, 스크립트가 지수 백오프 방식으로 자동 재시도합니다. 한도 자체를 높이려면 크레딧 구매 페이지에서 크레딧을 구매해 약정 등급(Commitment tier)을 올리세요.
Document Parse의 비동기 추론 요청 API를 사용하여 사용자는 최대 2,000페이지의 지원되는 문서에 대한 추론 요청을 제출할 수 있어요. 요청을 받으면 API는 즉시 요청 ID를 반환해요. 입력 파일이 전송되어 검증된 후, 10페이지 단위의 배치로 분할되어 각 배치에 대해 추론이 수행돼요. 사용자는 비동기 추론 요청 API의 응답을 통해 각 배치의 상태를 실시간으로 확인할 수 있어요. 각 배치의 추론 결과는 응답에 포함된 download_url을 통해 다운로드할 수 있어요. 결과는 요청 시점으로부터 30일 동안 다운로드할 수 있어요.
uploaded document is too large. max allowed size is 500MB
413
unsupported document format
415
배치 추론 오류: 엔진이 입력 문서를 10페이지 단위의 배치로 분할하기 때문에 각 배치는 모델 추론 상태가 서로 다를 수 있습니다. 입력 파일은 추론 전에 이미 검증되므로, 배치 추론 오류는 대부분 내부 서버 오류로 인해 발생합니다. 이 경우 결과 조회를 위한 API 응답의 batches 필드에 failed 상태와 failure_message가 표시됩니다.
요청 결과 조회 실패:
Document Parse 비동기 API에서 추론 결과는 30일 동안 저장되지만, download_url은 발급 후 15분 동안만 유효합니다. requests/{request_id}를 호출할 때마다 새로운 pre-signed URL이 생성됩니다. 만료된 URL에 접근하려고 하면 403 오류가 발생할 수 있습니다. 긴 문서의 경우, 여러 URL을 한 번에 생성하면 실패하는 경우가 있을 수 있습니다. 결과를 다시 가져오려면 동일한 request_id로 재조회하세요. 추가 크레딧 없이 새로운 download_url이 발급됩니다.