esuls 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {esuls-0.2.2/src/esuls.egg-info → esuls-0.2.4}/PKG-INFO +1 -1
- {esuls-0.2.2 → esuls-0.2.4}/pyproject.toml +1 -1
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/request_cli.py +168 -64
- {esuls-0.2.2 → esuls-0.2.4/src/esuls.egg-info}/PKG-INFO +1 -1
- {esuls-0.2.2 → esuls-0.2.4}/LICENSE +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/README.md +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/setup.cfg +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/__init__.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/db_cli.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/download_icon.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/__init__.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/checker.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/cli.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/inspect.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/metadata.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/_common.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/conftest.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_cross_loop.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_advanced.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_concurrent.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_fixes.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_migrations.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_sqla.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_pdf.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_request_cli.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls/utils.py +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/SOURCES.txt +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/dependency_links.txt +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/entry_points.txt +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/requires.txt +0 -0
- {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/top_level.txt +0 -0
|
@@ -812,14 +812,19 @@ async def _get_playwright_browser():
|
|
|
812
812
|
cached = state["playwright_browser"]
|
|
813
813
|
if cached is not None and not cached.is_connected():
|
|
814
814
|
logger.debug("Cached Playwright browser is disconnected; refreshing.")
|
|
815
|
+
# Bound the teardown: a dead/wedged browser can hang its own
|
|
816
|
+
# close()/stop(), and this runs while holding playwright_lock —
|
|
817
|
+
# an unbounded await here would block every other caller.
|
|
815
818
|
try:
|
|
816
|
-
|
|
819
|
+
async with asyncio.timeout(5):
|
|
820
|
+
await cached.close()
|
|
817
821
|
except Exception:
|
|
818
822
|
pass
|
|
819
823
|
try:
|
|
820
824
|
instance = state["playwright_instance"]
|
|
821
825
|
if instance is not None:
|
|
822
|
-
|
|
826
|
+
async with asyncio.timeout(5):
|
|
827
|
+
await instance.stop()
|
|
823
828
|
except Exception:
|
|
824
829
|
pass
|
|
825
830
|
state["playwright_browser"] = None
|
|
@@ -827,20 +832,69 @@ async def _get_playwright_browser():
|
|
|
827
832
|
|
|
828
833
|
if state["playwright_browser"] is None:
|
|
829
834
|
from playwright.async_api import async_playwright
|
|
830
|
-
|
|
831
|
-
#
|
|
832
|
-
#
|
|
833
|
-
#
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
"
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
835
|
+
# Bound start()+launch(): a hung driver spawn or Chromium launch
|
|
836
|
+
# would otherwise hold playwright_lock forever and deadlock every
|
|
837
|
+
# make_request_playwright caller. On failure, clear any half-built
|
|
838
|
+
# state so we don't leak a driver subprocess or reuse a broken
|
|
839
|
+
# cache slot, then re-raise for the caller's retry path.
|
|
840
|
+
try:
|
|
841
|
+
async with asyncio.timeout(60):
|
|
842
|
+
state["playwright_instance"] = await async_playwright().start()
|
|
843
|
+
# Stealth handles fingerprint surface (webdriver flag,
|
|
844
|
+
# plugins, WebGL, chrome runtime, UA, etc.); browser args
|
|
845
|
+
# here are only sandbox/IPC concerns for headless runtime.
|
|
846
|
+
state["playwright_browser"] = await state["playwright_instance"].chromium.launch(
|
|
847
|
+
headless=True,
|
|
848
|
+
args=[
|
|
849
|
+
"--no-sandbox",
|
|
850
|
+
"--disable-dev-shm-usage",
|
|
851
|
+
],
|
|
852
|
+
)
|
|
853
|
+
except BaseException:
|
|
854
|
+
inst = state["playwright_instance"]
|
|
855
|
+
state["playwright_browser"] = None
|
|
856
|
+
state["playwright_instance"] = None
|
|
857
|
+
if inst is not None:
|
|
858
|
+
try:
|
|
859
|
+
async with asyncio.timeout(5):
|
|
860
|
+
await inst.stop()
|
|
861
|
+
except Exception:
|
|
862
|
+
pass
|
|
863
|
+
raise
|
|
841
864
|
return state["playwright_browser"]
|
|
842
865
|
|
|
843
866
|
|
|
867
|
+
async def _reset_playwright_browser() -> None:
|
|
868
|
+
"""Force-discard the cached Chromium so the next _get_playwright_browser()
|
|
869
|
+
relaunches a fresh process.
|
|
870
|
+
|
|
871
|
+
Used when a browser-level await hangs: a single page can wedge the shared
|
|
872
|
+
browser (the process stays alive, so is_connected() keeps returning True)
|
|
873
|
+
and then every new_page() on it blocks forever. The cache slot is cleared
|
|
874
|
+
under the lock first; the actual close() runs *outside* the lock and is
|
|
875
|
+
bounded, so a hung close() can't stop other callers from acquiring the
|
|
876
|
+
lock to launch a replacement.
|
|
877
|
+
"""
|
|
878
|
+
state = _req_loop_state()
|
|
879
|
+
async with state["playwright_lock"]:
|
|
880
|
+
browser = state["playwright_browser"]
|
|
881
|
+
instance = state["playwright_instance"]
|
|
882
|
+
state["playwright_browser"] = None
|
|
883
|
+
state["playwright_instance"] = None
|
|
884
|
+
if browser is not None:
|
|
885
|
+
try:
|
|
886
|
+
async with asyncio.timeout(5):
|
|
887
|
+
await browser.close()
|
|
888
|
+
except Exception:
|
|
889
|
+
pass
|
|
890
|
+
if instance is not None:
|
|
891
|
+
try:
|
|
892
|
+
async with asyncio.timeout(5):
|
|
893
|
+
await instance.stop()
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
|
|
897
|
+
|
|
844
898
|
async def make_request_playwright(
|
|
845
899
|
url: str,
|
|
846
900
|
timeout_request: int = 15,
|
|
@@ -867,60 +921,110 @@ async def make_request_playwright(
|
|
|
867
921
|
(analytics, polling, websockets) are cut off at `wait_seconds`
|
|
868
922
|
and we proceed with the partial render.
|
|
869
923
|
"""
|
|
870
|
-
|
|
924
|
+
# set_default_timeout() below governs only Playwright actions issued
|
|
925
|
+
# AFTER it; it never covers browser.new_page(), stealth_async(), or
|
|
926
|
+
# page.close(). A wedged Chromium (a prior page hung the process while
|
|
927
|
+
# is_connected() still reports True) makes those awaits block forever
|
|
928
|
+
# with no bound, deadlocking every caller that shares the browser. Wrap
|
|
929
|
+
# each attempt's whole page lifecycle in one hard wall-clock budget so a
|
|
930
|
+
# single bad site can never hang the process. The budget comfortably
|
|
931
|
+
# exceeds the sum of the inner per-action timeouts (goto + networkidle +
|
|
932
|
+
# content), so it only trips on a genuine hang, not a merely slow page.
|
|
933
|
+
attempt_budget = 2 * timeout_request + wait_seconds + 15
|
|
871
934
|
|
|
872
935
|
for attempt in range(max_attempt):
|
|
873
936
|
page = None
|
|
874
937
|
try:
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
938
|
+
browser = await _get_playwright_browser()
|
|
939
|
+
async with asyncio.timeout(attempt_budget):
|
|
940
|
+
page = await browser.new_page()
|
|
941
|
+
await stealth_async(page)
|
|
942
|
+
page.set_default_timeout(timeout_request * 1000)
|
|
943
|
+
|
|
944
|
+
# Track the final main-frame document response. JS cookie
|
|
945
|
+
# challenges (Sucuri CloudProxy, some Cloudflare/HSTS 307s)
|
|
946
|
+
# answer the first request with a 307 + a script that sets a
|
|
947
|
+
# cookie and calls location.reload(); the real page then loads
|
|
948
|
+
# with a fresh 200. page.goto() only returns that first
|
|
949
|
+
# response (the 307), so we follow the main frame's last
|
|
950
|
+
# navigation response to report the status/headers of the
|
|
951
|
+
# document actually rendered.
|
|
952
|
+
final_doc: dict = {"resp": None}
|
|
953
|
+
|
|
954
|
+
def _track_main_doc(r):
|
|
955
|
+
try:
|
|
956
|
+
if (r.request.is_navigation_request()
|
|
957
|
+
and r.frame is page.main_frame):
|
|
958
|
+
final_doc["resp"] = r
|
|
959
|
+
except Exception:
|
|
960
|
+
# Frame/request may already be torn down mid-event.
|
|
961
|
+
pass
|
|
962
|
+
|
|
963
|
+
page.on("response", _track_main_doc)
|
|
964
|
+
|
|
965
|
+
resp = await page.goto(url, wait_until="domcontentloaded")
|
|
966
|
+
# Wait for network activity to settle (more robust than a fixed
|
|
967
|
+
# sleep for SPAs). Bounded by `wait_seconds` so pages with
|
|
968
|
+
# endless background traffic don't hang forever.
|
|
969
|
+
if wait_seconds > 0:
|
|
970
|
+
try:
|
|
971
|
+
await page.wait_for_load_state(
|
|
972
|
+
"networkidle",
|
|
973
|
+
timeout=int(wait_seconds * 1000),
|
|
974
|
+
)
|
|
975
|
+
except Exception as wait_err:
|
|
976
|
+
# Most likely a Playwright TimeoutError: page kept
|
|
977
|
+
# making requests beyond wait_seconds. Proceed with
|
|
978
|
+
# whatever has rendered so far.
|
|
979
|
+
logger.debug(
|
|
980
|
+
f"networkidle wait expired, proceeding: {wait_err}"
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
doc_resp = final_doc["resp"] or resp
|
|
984
|
+
page_source = await page.content()
|
|
985
|
+
final_url = page.url
|
|
986
|
+
status_code = doc_resp.status if doc_resp else 200
|
|
987
|
+
|
|
988
|
+
response = Response(
|
|
989
|
+
status_code=status_code,
|
|
990
|
+
headers=dict(doc_resp.headers) if doc_resp else {},
|
|
991
|
+
_content=page_source.encode("utf-8"),
|
|
992
|
+
text=page_source,
|
|
993
|
+
url=final_url,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
if status_code not in _SUCCESS_STATUS_RANGE:
|
|
893
997
|
logger.debug(
|
|
894
|
-
f"
|
|
998
|
+
f"playwright request: {status_code} - {url} "
|
|
999
|
+
f"- attempt {attempt + 1}/{max_attempt}"
|
|
895
1000
|
)
|
|
1001
|
+
if (no_retry_status_codes
|
|
1002
|
+
and status_code in no_retry_status_codes):
|
|
1003
|
+
return response if force_response else None
|
|
1004
|
+
if attempt + 1 == max_attempt:
|
|
1005
|
+
return response if force_response else None
|
|
1006
|
+
await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
|
|
1007
|
+
continue
|
|
896
1008
|
|
|
897
|
-
|
|
898
|
-
final_url = page.url
|
|
899
|
-
status_code = resp.status if resp else 200
|
|
1009
|
+
return response
|
|
900
1010
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1011
|
+
except TimeoutError:
|
|
1012
|
+
# Hard wall-clock budget exceeded. This is the builtin
|
|
1013
|
+
# TimeoutError from asyncio.timeout() — distinct from Playwright's
|
|
1014
|
+
# own TimeoutError (a separate class, caught below as an ordinary
|
|
1015
|
+
# slow-page retry). Reaching here means an await with no inner
|
|
1016
|
+
# bound hung (almost always new_page() on a wedged browser), so
|
|
1017
|
+
# discard the shared browser; the next attempt relaunches a clean
|
|
1018
|
+
# Chromium instead of hanging on the same dead process.
|
|
1019
|
+
logger.debug(
|
|
1020
|
+
f"playwright attempt exceeded {attempt_budget}s budget "
|
|
1021
|
+
f"(browser likely wedged) - {url} "
|
|
1022
|
+
f"- attempt {attempt + 1}/{max_attempt}"
|
|
907
1023
|
)
|
|
908
|
-
|
|
909
|
-
if
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
f"- attempt {attempt + 1}/{max_attempt}"
|
|
913
|
-
)
|
|
914
|
-
if (no_retry_status_codes
|
|
915
|
-
and status_code in no_retry_status_codes):
|
|
916
|
-
return response if force_response else None
|
|
917
|
-
if attempt + 1 == max_attempt:
|
|
918
|
-
return response if force_response else None
|
|
919
|
-
await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
|
|
920
|
-
continue
|
|
921
|
-
|
|
922
|
-
return response
|
|
923
|
-
|
|
1024
|
+
await _reset_playwright_browser()
|
|
1025
|
+
if attempt + 1 == max_attempt:
|
|
1026
|
+
return None
|
|
1027
|
+
await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
|
|
924
1028
|
except Exception as e:
|
|
925
1029
|
logger.debug(
|
|
926
1030
|
f"playwright request error: {e} - {url} "
|
|
@@ -930,14 +1034,14 @@ async def make_request_playwright(
|
|
|
930
1034
|
return None
|
|
931
1035
|
await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
|
|
932
1036
|
finally:
|
|
933
|
-
# page.close() can itself fail when the browser is in a
|
|
934
|
-
# state.
|
|
935
|
-
# remaining attempts; we'd rather lose the page (it's about
|
|
936
|
-
#
|
|
937
|
-
# cleanup exception.
|
|
1037
|
+
# page.close() can itself hang or fail when the browser is in a
|
|
1038
|
+
# bad state. Bound it and swallow errors so the retry loop can run
|
|
1039
|
+
# its remaining attempts; we'd rather lose the page (it's about to
|
|
1040
|
+
# be GC'd anyway) than block on cleanup.
|
|
938
1041
|
if page is not None:
|
|
939
1042
|
try:
|
|
940
|
-
|
|
1043
|
+
async with asyncio.timeout(5):
|
|
1044
|
+
await page.close()
|
|
941
1045
|
except Exception as close_err:
|
|
942
1046
|
logger.debug(f"page.close() failed (ignored): {close_err}")
|
|
943
1047
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|