-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscraper.py
More file actions
423 lines (337 loc) · 13.2 KB
/
Copy pathscraper.py
File metadata and controls
423 lines (337 loc) · 13.2 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
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
"""
Personal Scraper — Lightweight Firecrawl Alternative
Hybrid static-first / dynamic-fallback web scraper designed for
content research, competitor analysis, and prospect website intake.
Tries a fast requests-based fetch first; falls back to a headless
Playwright browser with stealth patching when the static result is
thin or the server refuses the plain request.
Usage (CLI):
python scraper.py <url> [--dynamic | --static] [--output FILE] [--quiet]
Usage (module):
from scraper import scrape
text = scrape("https://www.fiercehealthcare.com")
"""
import argparse
import random
import re
import sys
import time
from typing import Optional
import requests
from bs4 import BeautifulSoup
# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------
HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
# Keep static responses simple. Some sites return compressed bytes that
# requests cannot decode cleanly in this environment when br is advertised.
"Accept-Encoding": "gzip, deflate",
"Accept": (
"text/html,application/xhtml+xml,application/xml;"
"q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
),
}
# Minimum character threshold — results shorter than this trigger fallback.
_STATIC_MIN_CHARS: int = 300
# Tags that add noise without informational value.
_NOISE_TAGS: tuple[str, ...] = (
"script",
"style",
"nav",
"footer",
"header",
"aside",
"noscript",
)
# ---------------------------------------------------------------------------
# HTML cleaning
# ---------------------------------------------------------------------------
def clean_html(html: str) -> str:
"""Parse raw HTML and return clean, readable plain text.
Removes script, style, nav, footer, header, aside, and noscript
elements before extracting text. Collapses runs of three or more
consecutive blank lines down to a single blank line.
Args:
html: Raw HTML string as returned by a browser or requests.
Returns:
Cleaned plain-text string suitable for feeding to an LLM.
"""
soup = BeautifulSoup(html, "lxml")
for tag in _NOISE_TAGS:
for element in soup.find_all(tag):
element.decompose()
text: str = soup.get_text(separator="\n", strip=True)
# Collapse 3+ consecutive newlines to exactly 2.
text = re.sub(r"\n{3,}", "\n\n", text)
return text
# ---------------------------------------------------------------------------
# Static fetcher
# ---------------------------------------------------------------------------
def scrape_static(url: str) -> str:
"""Fetch a URL with a plain HTTP request and return cleaned text.
Uses a realistic Chrome 124 User-Agent header set. Raises an
exception on any HTTP error status (4xx / 5xx).
Args:
url: Fully-qualified URL to fetch.
Returns:
Cleaned plain-text content of the page.
Raises:
requests.HTTPError: On 4xx or 5xx responses.
requests.RequestException: On connection or timeout failures.
"""
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
return clean_html(response.text)
# ---------------------------------------------------------------------------
# Dynamic fetcher (Playwright — lazy import)
# ---------------------------------------------------------------------------
def scrape_dynamic(url: str) -> str:
"""Fetch a URL using a headless Chromium browser with stealth patching.
Playwright and playwright_stealth are imported lazily inside this
function so that the module can be used for static-only scraping
without those packages installed.
Navigates to the URL, waits for the network to go idle, then waits
an additional random 1.5–3.0 seconds to allow JS rendering to
complete before capturing page content.
Args:
url: Fully-qualified URL to fetch.
Returns:
Cleaned plain-text content of the rendered page.
Raises:
playwright._impl._errors.TimeoutError: If page load exceeds 30 s.
Exception: Any Playwright-level launch or navigation error.
"""
# Lazy imports so static-only usage doesn't require playwright installed.
from playwright.sync_api import sync_playwright # noqa: PLC0415
from playwright_stealth import stealth_sync # noqa: PLC0415
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context(extra_http_headers=HEADERS)
page = context.new_page()
stealth_sync(page)
page.goto(url, wait_until="networkidle", timeout=30000)
time.sleep(random.uniform(1.5, 3.0))
html = page.content()
browser.close()
return clean_html(html)
# ---------------------------------------------------------------------------
# Hybrid entry point
# ---------------------------------------------------------------------------
def scrape(
url: str,
force_dynamic: bool = False,
force_static: bool = False,
) -> str:
"""Scrape a URL and return clean AI-readable plain text.
Hybrid logic:
- If force_dynamic is True, always use the Playwright browser path.
- If force_static is True, always use the plain requests path.
- Otherwise, attempt the fast static fetch first. If the result is
fewer than 300 characters (thin/blocked content) OR any exception
is raised, fall back to the dynamic browser path and print a
diagnostic message to stderr.
Args:
url: Fully-qualified URL to scrape.
force_dynamic: Skip static attempt and go straight to Playwright.
force_static: Skip dynamic fallback and only use requests.
Returns:
Cleaned plain-text content of the page.
Raises:
Exception: Propagates any exception from the chosen scraper when
the fallback path is also disabled (force_static=True) or
when the dynamic path itself fails.
"""
if force_dynamic:
return scrape_dynamic(url)
if force_static:
return scrape_static(url)
# Hybrid: static first, dynamic fallback.
fallback_reason: Optional[str] = None
try:
result = scrape_static(url)
if len(result.strip()) < _STATIC_MIN_CHARS:
fallback_reason = (
f"static result too short ({len(result.strip())} chars "
f"< {_STATIC_MIN_CHARS} threshold)"
)
else:
return result
except Exception as exc: # noqa: BLE001
fallback_reason = f"static fetch failed ({type(exc).__name__}: {exc})"
print(
f"[fallback] Switching to dynamic scraper — {fallback_reason}",
file=sys.stderr,
)
return scrape_dynamic(url)
# ---------------------------------------------------------------------------
# Raw HTML fetcher (for credibility layer)
# ---------------------------------------------------------------------------
def fetch_raw_html(
url: str,
force_dynamic: bool = False,
force_static: bool = False,
) -> str:
"""Fetch a URL and return the unprocessed HTML string.
Uses the same hybrid logic as scrape() but returns raw HTML instead of
cleaned plain text. Intended to be called alongside scrape() when the
caller needs both the cleaned text and the raw HTML for the credibility
hidden-injection scanner.
The raw HTML is not cleaned or filtered in any way — it contains all
hidden elements, HTML comments, meta tags, and style attributes that
clean_html() would strip.
Args:
url: Fully-qualified URL to fetch.
force_dynamic: Skip static attempt and use Playwright directly.
force_static: Use requests only — no browser fallback.
Returns:
Raw HTML string. Empty string on any fetch failure.
"""
if force_dynamic:
return _fetch_raw_dynamic(url)
if force_static:
try:
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
return response.text
except Exception:
return ""
# Hybrid: static first
try:
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
if len(response.text.strip()) >= _STATIC_MIN_CHARS:
return response.text
except Exception:
pass
return _fetch_raw_dynamic(url)
def _fetch_raw_dynamic(url: str) -> str:
"""Fetch raw HTML via Playwright without cleaning."""
try:
from playwright.sync_api import sync_playwright # noqa: PLC0415
from playwright_stealth import stealth_sync # noqa: PLC0415
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context(extra_http_headers=HEADERS)
page = context.new_page()
stealth_sync(page)
page.goto(url, wait_until="networkidle", timeout=30000)
time.sleep(random.uniform(1.5, 3.0))
html = page.content()
browser.close()
return html
except Exception:
return ""
def scrape_with_raw(
url: str,
force_dynamic: bool = False,
force_static: bool = False,
) -> tuple[str, str]:
"""Scrape a URL and return both cleaned text and raw HTML.
Runs a single fetch (respecting force flags and hybrid fallback logic)
and returns both outputs without a second network request.
Args:
url: Fully-qualified URL to scrape.
force_dynamic: Skip static attempt and use Playwright directly.
force_static: Use requests only — no browser fallback.
Returns:
(clean_text, raw_html) tuple. On failure, clean_text begins with
'ERROR: ' and raw_html is an empty string.
"""
if force_dynamic:
raw = _fetch_raw_dynamic(url)
if not raw:
return "ERROR: dynamic fetch returned empty", ""
return clean_html(raw), raw
if force_static:
try:
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
return clean_html(response.text), response.text
except Exception as exc:
return f"ERROR: {type(exc).__name__}: {exc}", ""
# Hybrid
fallback_reason: Optional[str] = None
try:
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
raw = response.text
cleaned = clean_html(raw)
if len(cleaned.strip()) >= _STATIC_MIN_CHARS:
return cleaned, raw
fallback_reason = (
f"static result too short ({len(cleaned.strip())} chars "
f"< {_STATIC_MIN_CHARS} threshold)"
)
except Exception as exc:
fallback_reason = f"static fetch failed ({type(exc).__name__}: {exc})"
print(
f"[fallback] Switching to dynamic scraper — {fallback_reason}",
file=sys.stderr,
)
raw = _fetch_raw_dynamic(url)
return (clean_html(raw) if raw else "ERROR: dynamic fetch returned empty"), raw
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="scraper",
description=(
"Hybrid static-first / dynamic-fallback web scraper. "
"Returns clean AI-readable plain text from any public URL."
),
)
parser.add_argument("url", help="URL to scrape.")
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"--dynamic",
action="store_true",
help="Force headless Playwright browser (skip static attempt).",
)
mode_group.add_argument(
"--static",
action="store_true",
help="Force plain requests fetch (no browser fallback).",
)
parser.add_argument(
"--output",
metavar="FILE",
help="Write output to FILE instead of stdout.",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress fallback diagnostic messages on stderr.",
)
return parser
if __name__ == "__main__":
parser = _build_parser()
args = parser.parse_args()
# When --quiet is set, redirect stderr to /dev/null equivalent by
# temporarily replacing it so the fallback print inside scrape()
# goes nowhere.
if args.quiet:
sys.stderr = open( # noqa: WPS515
"nul" if sys.platform == "win32" else "/dev/null", "w"
)
text = scrape(
url=args.url,
force_dynamic=args.dynamic,
force_static=args.static,
)
if args.output:
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(text)
# Restore stderr before printing confirmation so the user sees it
# even with --quiet.
if args.quiet:
sys.stderr = sys.__stderr__
print(f"Output written to {args.output}", file=sys.stderr)
else:
print(text)