forked from Instapaper/instaparser-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.py
More file actions
383 lines (326 loc) · 13 KB
/
client.py
File metadata and controls
383 lines (326 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
"""
InstaparserClient - Main client class for the Instaparser API.
"""
import json
import uuid
from collections.abc import Callable
from http.client import HTTPResponse
from typing import Any, BinaryIO, NoReturn
from urllib.error import HTTPError
from urllib.parse import urlencode, urljoin
from urllib.request import Request, urlopen
from .article import Article
from .exceptions import (
InstaparserAPIError,
InstaparserAuthenticationError,
InstaparserRateLimitError,
InstaparserValidationError,
)
from .pdf import PDF
from .summary import Summary
def _encode_multipart_formdata(
fields: dict[str, str],
files: dict[str, BinaryIO | bytes],
) -> tuple[bytes, str]:
"""
Encode fields and files as multipart/form-data.
Args:
fields: Dictionary of form field name/value pairs
files: Dictionary of file field name to file-like object or bytes
Returns:
Tuple of (encoded body bytes, content-type header value)
"""
boundary = uuid.uuid4().hex
lines: list[bytes] = []
for name, value in fields.items():
lines.append(f"--{boundary}\r\n".encode())
lines.append(f'Content-Disposition: form-data; name="{name}"\r\n'.encode())
lines.append(b"\r\n")
lines.append(f"{value}\r\n".encode())
for name, file_data in files.items():
if hasattr(file_data, "read"):
data = file_data.read()
else:
data = file_data
lines.append(f"--{boundary}\r\n".encode())
lines.append(f'Content-Disposition: form-data; name="{name}"; filename="upload"\r\n'.encode())
lines.append(b"Content-Type: application/octet-stream\r\n")
lines.append(b"\r\n")
lines.append(data)
lines.append(b"\r\n")
lines.append(f"--{boundary}--\r\n".encode())
body = b"".join(lines)
content_type = f"multipart/form-data; boundary={boundary}"
return body, content_type
def _map_http_error(e: HTTPError) -> NoReturn:
"""
Translate an HTTPError into the appropriate Instaparser domain exception.
Reads the response body from the HTTPError, extracts the ``reason``
field from the JSON payload (if present), and raises the matching
Instaparser exception.
"""
status_code = e.code
body = e.read().decode("utf-8")
error_message = f"API request failed with status {status_code}"
try:
error_data = json.loads(body)
if isinstance(error_data, dict) and "reason" in error_data:
error_message = error_data["reason"]
except (ValueError, json.JSONDecodeError):
error_message = body or error_message
errors: dict[int, tuple[type[InstaparserAPIError], str]] = {
400: (InstaparserValidationError, "Invalid request"),
401: (InstaparserAuthenticationError, "Invalid API key"),
403: (InstaparserAPIError, "Account suspended"),
409: (InstaparserAPIError, "Exceeded monthly API calls"),
429: (InstaparserRateLimitError, "Rate limit exceeded"),
}
exc_cls, default_msg = errors.get(status_code, (InstaparserAPIError, ""))
raise exc_cls(error_message or default_msg, status_code=status_code, response=e)
class InstaparserClient:
"""
Client for interacting with the Instaparser API.
Example:
>>> client = InstaparserClient(api_key="your-api-key")
>>> article = client.Article(url="https://example.com/article")
>>> print(article.body)
"""
BASE_URL = "https://instaparser.com"
def __init__(self, api_key: str, base_url: str | None = None):
"""
Initialize the Instaparser client.
Args:
api_key: Your Instaparser API key
base_url: Optional base URL for the API (defaults to production)
"""
self.api_key = api_key
self.base_url = base_url or self.BASE_URL
self.headers = {
"Authorization": f"Bearer {api_key}",
}
def __repr__(self) -> str:
return f"<InstaparserClient base_url={self.base_url!r}>"
def _request(
self,
method: str,
path: str,
*,
json_data: dict | None = None,
params: dict | None = None,
multipart_fields: dict[str, str] | None = None,
multipart_files: dict[str, BinaryIO | bytes] | None = None,
) -> HTTPResponse:
"""
Make an HTTP request using urllib.
Args:
method: HTTP method (GET, POST)
path: API endpoint path (e.g. "/api/1/article")
json_data: JSON body payload
params: Query parameters
multipart_fields: Form fields for multipart upload
multipart_files: Files for multipart upload
Returns:
HTTPResponse on success
Raises:
HTTPError: On non-2xx status codes (callers convert via _map_http_error)
"""
url = urljoin(self.base_url, path)
if params:
url = f"{url}?{urlencode(params)}"
data = None
headers = self.headers.copy()
if multipart_fields or multipart_files:
data, content_type = _encode_multipart_formdata(multipart_fields or {}, multipart_files or {})
headers["Content-Type"] = content_type
elif json_data is not None:
data = json.dumps(json_data).encode("utf-8")
headers["Content-Type"] = "application/json"
req = Request(url, data=data, headers=headers, method=method)
response: HTTPResponse = urlopen(req)
return response
def _read_json(self, response: HTTPResponse) -> dict[str, Any]:
"""
Read and parse a successful JSON response.
Returns:
Parsed JSON dict, or ``{"raw": text}`` if the body isn't valid JSON.
"""
body = response.read().decode("utf-8")
try:
parsed = json.loads(body)
if isinstance(parsed, dict):
return parsed
except (ValueError, json.JSONDecodeError):
pass
return {"raw": body}
def Article(self, url: str, content: str | None = None, output: str = "html", use_cache: bool = True) -> Article:
"""
Parse an article from a URL or HTML content.
Args:
url: URL of the article to parse (required)
content: Optional raw HTML content to parse instead of fetching from URL
output: Output format - 'html' (default), 'text', or 'markdown'
use_cache: Whether to use cache (default: True)
Returns:
Article object with parsed content
Example:
>>> article = client.Article(url="https://example.com/article")
>>> print(article.title)
>>> print(article.body)
"""
if output not in ("html", "text", "markdown"):
raise InstaparserValidationError("output must be 'html', 'text', or 'markdown'")
payload: dict[str, Any] = {
"url": url,
"output": output,
}
if not use_cache:
payload["use_cache"] = "false"
if content is not None:
payload["content"] = content
try:
response = self._request("POST", "/api/1/article", json_data=payload)
except HTTPError as e:
_map_http_error(e)
data = self._read_json(response)
return Article(
url=data.get("url"),
title=data.get("title"),
site_name=data.get("site_name"),
author=data.get("author"),
date=data.get("date"),
description=data.get("description"),
thumbnail=data.get("thumbnail"),
words=data.get("words", 0),
is_rtl=data.get("is_rtl", False),
images=data.get("images", []),
videos=data.get("videos", []),
html=data.get("html"),
text=data.get("text"),
markdown=data.get("markdown"),
)
def Summary(
self,
url: str,
content: str | None = None,
use_cache: bool = True,
stream_callback: Callable[[str], None] | None = None,
) -> Summary:
"""
Generate a summary of an article.
Args:
url: URL of the article to summarize (required)
content: Optional HTML content to parse instead of fetching from URL
use_cache: Whether to use cache (default: True)
stream_callback: Optional callback function called for each line of streaming response.
If provided, enables streaming mode. The callback receives each line as a string.
Returns:
Summary object with key_sentences and overview attributes
Example:
>>> summary = client.Summary(url="https://example.com/article")
>>> print(summary.overview)
>>> print(summary.key_sentences)
>>> # With streaming callback
>>> def on_line(line):
... print(f"Received: {line}")
>>> summary = client.Summary(url="https://example.com/article", stream_callback=on_line)
"""
payload: dict[str, Any] = {
"url": url,
"stream": stream_callback is not None,
}
if not use_cache:
payload["use_cache"] = "false"
if content is not None:
payload["content"] = content
try:
response = self._request("POST", "/api/1/summary", json_data=payload)
except HTTPError as e:
_map_http_error(e)
if stream_callback is not None:
key_sentences: list[str] = []
overview = ""
for raw_line in response:
line = raw_line.strip(b"\r\n")
if line:
line_str = line.decode("utf-8")
stream_callback(line_str)
if line_str.startswith("key_sentences:"):
try:
key_sentences = json.loads(line_str.split(":", 1)[1].strip())
except json.JSONDecodeError:
raise InstaparserAPIError("Unable to generate key sentences", status_code=412)
elif line_str.startswith("delta:"):
delta_content = line_str.split(": ", 1)[1]
overview += delta_content
return Summary(key_sentences=key_sentences, overview=overview)
else:
data = self._read_json(response)
return Summary(key_sentences=data.get("key_sentences", []), overview=data.get("overview", ""))
def PDF(
self,
url: str | None = None,
file: BinaryIO | bytes | None = None,
output: str = "html",
use_cache: bool = True,
) -> PDF:
"""
Parse a PDF from a URL or file.
Args:
url: URL of the PDF to parse (required for GET request)
file: PDF file to upload (required for POST request, can be file-like object or bytes)
output: Output format - 'html' (default), 'text', or 'markdown'
use_cache: Whether to use cache (default: True)
Returns:
PDF object with parsed PDF content (inherits from Article)
Example:
>>> # Parse PDF from URL
>>> pdf = client.PDF(url="https://example.com/document.pdf")
>>> # Parse PDF from file
>>> with open('document.pdf', 'rb') as f:
... pdf = client.PDF(file=f)
"""
if output not in ("html", "text", "markdown"):
raise InstaparserValidationError("output must be 'html', 'text', or 'markdown'")
if file is not None:
fields = {"output": output}
if not use_cache:
fields["use_cache"] = "false"
if url:
fields["url"] = url
try:
response = self._request(
"POST",
"/api/1/pdf",
multipart_fields=fields,
multipart_files={"file": file},
)
except HTTPError as e:
_map_http_error(e)
elif url:
params = {
"url": url,
"output": output,
}
if not use_cache:
params["use_cache"] = "false"
try:
response = self._request("GET", "/api/1/pdf", params=params)
except HTTPError as e:
_map_http_error(e)
else:
raise InstaparserValidationError("Either 'url' or 'file' must be provided")
result = self._read_json(response)
return PDF(
url=result.get("url"),
title=result.get("title"),
site_name=result.get("site_name"),
author=result.get("author"),
date=result.get("date"),
description=result.get("description"),
thumbnail=result.get("thumbnail"),
words=result.get("words", 0),
images=result.get("images", []),
html=result.get("html"),
text=result.get("text"),
markdown=result.get("markdown"),
)