Spaces:
Sleeping
Add pytest harness with characterization tests for scrap/ops/serp/utils
Browse filesAdds a test safety net ahead of any refactoring, per the TDD/architecture
review of this codebase (no test infrastructure existed before this):
- pytest + pytest-asyncio + respx as dev-only deps (requirements-dev.txt)
- Pure-function tests for SerpQuery's n_results clamping, utils.py's
exception logging, and every OPS JSON-walking helper (ops.py handles
OPS's XML-to-JSON quirk where a repeated element is a list but a single
one stays a bare dict - tests cover both shapes for each helper)
- HTTP-mocked (respx) tests for scrap_patent_async's HTML parsing
(abstract/description/claims/classification-code extraction, plus the
documented ValueError path for an unexpected page layout), the OPS
token manager's caching/refresh/retry-on-stale-token behavior, and
query_arxiv's Atom feed parsing
- A DDGS-mocked test pinning the DuckDuckGo empty-results-means-blocked
fix from d341a7a, so a regression there fails a test instead of
shipping silently
- Real-headless-Chromium tests for the four Playwright-driven scrapers
(Bing, Brave, Google Scholar, Google Patents search), using a
goto-patching Browser/Context wrapper (tests/helpers.py) to serve
fixture HTML instead of hitting live search engines - documented there
because the more obvious page.route() interception approach doesn't
work against these functions as currently written (they register their
own catch-all route internally, which wins and calls continue_()
instead of falling back)
WIP note: one Playwright test currently hangs under investigation
(test_brave_search_detects_the_suspicious_block_page) - follow-up commit
will fix it before this is considered done.
- pytest.ini +3 -0
- requirements-dev.txt +7 -0
- tests/conftest.py +48 -0
- tests/fixtures/arxiv_response.xml +17 -0
- tests/fixtures/bing_results.html +13 -0
- tests/fixtures/brave_blocked.html +6 -0
- tests/fixtures/brave_results.html +13 -0
- tests/fixtures/ops/biblio_response.json +37 -0
- tests/fixtures/ops/claims_response.json +15 -0
- tests/fixtures/ops/description_response.json +15 -0
- tests/fixtures/ops/search_not_found_response.json +6 -0
- tests/fixtures/ops/search_response.json +45 -0
- tests/fixtures/ops/token_response.json +5 -0
- tests/fixtures/patents/full_patent.html +41 -0
- tests/fixtures/patents/missing_title.html +8 -0
- tests/fixtures/patents/search_results.html +15 -0
- tests/fixtures/scholar_results.html +13 -0
- tests/helpers.py +73 -0
- tests/test_ops_client.py +180 -0
- tests/test_ops_parsing.py +215 -0
- tests/test_scrap.py +68 -0
- tests/test_serp_arxiv.py +31 -0
- tests/test_serp_ddg.py +44 -0
- tests/test_serp_models.py +25 -0
- tests/test_serp_playwright.py +88 -0
- tests/test_utils.py +43 -0
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
asyncio_mode = auto
|
| 3 |
+
testpaths = tests
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Test-only dependencies. Install with:
|
| 2 |
+
# pip install -r requirements.txt -r requirements-dev.txt
|
| 3 |
+
# playwright install chromium # if not already present on the machine
|
| 4 |
+
-r requirements.txt
|
| 5 |
+
pytest>=8,<10
|
| 6 |
+
pytest-asyncio>=0.24
|
| 7 |
+
respx>=0.21
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest_asyncio
|
| 6 |
+
|
| 7 |
+
# The app modules (serp.py, scrap.py, ops.py, utils.py) live at the repo
|
| 8 |
+
# root, not inside a package - make sure they're importable regardless of
|
| 9 |
+
# how pytest resolves its rootdir.
|
| 10 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 11 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _find_chromium_executable() -> str | None:
|
| 15 |
+
"""Fallback lookup for environments where the browser Playwright's pip
|
| 16 |
+
package expects doesn't match what's actually on disk (e.g. a sandbox
|
| 17 |
+
image that pre-installs a specific Chromium revision separately from
|
| 18 |
+
however `pip install playwright` resolved). Mirrors the workaround
|
| 19 |
+
documented for the JS Playwright test runner in this environment.
|
| 20 |
+
"""
|
| 21 |
+
browsers_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
|
| 22 |
+
if not browsers_path:
|
| 23 |
+
return None
|
| 24 |
+
root = Path(browsers_path)
|
| 25 |
+
if not root.is_dir():
|
| 26 |
+
return None
|
| 27 |
+
candidates = sorted(root.glob("chromium-*/chrome-linux/chrome"))
|
| 28 |
+
return str(candidates[-1]) if candidates else None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest_asyncio.fixture(scope="session")
|
| 32 |
+
async def browser():
|
| 33 |
+
"""A single real headless Chromium instance shared by the whole test
|
| 34 |
+
session, exactly like the one `app.py`'s lifespan starts in production.
|
| 35 |
+
"""
|
| 36 |
+
from playwright.async_api import async_playwright
|
| 37 |
+
|
| 38 |
+
pw = await async_playwright().start()
|
| 39 |
+
try:
|
| 40 |
+
b = await pw.chromium.launch(headless=True)
|
| 41 |
+
except Exception:
|
| 42 |
+
executable_path = _find_chromium_executable()
|
| 43 |
+
if not executable_path:
|
| 44 |
+
raise
|
| 45 |
+
b = await pw.chromium.launch(headless=True, executable_path=executable_path)
|
| 46 |
+
yield b
|
| 47 |
+
await b.close()
|
| 48 |
+
await pw.stop()
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
| 3 |
+
<entry>
|
| 4 |
+
<id>http://arxiv.org/abs/2401.00001v1</id>
|
| 5 |
+
<title>
|
| 6 |
+
A Study of Widgets
|
| 7 |
+
</title>
|
| 8 |
+
<summary>
|
| 9 |
+
This paper studies widgets in depth.
|
| 10 |
+
</summary>
|
| 11 |
+
</entry>
|
| 12 |
+
<entry>
|
| 13 |
+
<id>http://arxiv.org/abs/2401.00002v1</id>
|
| 14 |
+
<title>Gadgets and Their Applications</title>
|
| 15 |
+
<summary>An overview of gadgets.</summary>
|
| 16 |
+
</entry>
|
| 17 |
+
</feed>
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<body>
|
| 4 |
+
<li class="b_algo">
|
| 5 |
+
<h2><a href="https://example.org/a">Result A</a></h2>
|
| 6 |
+
<div class="b_caption"><p>Snippet A</p></div>
|
| 7 |
+
</li>
|
| 8 |
+
<li class="b_algo">
|
| 9 |
+
<h2><a href="https://example.org/b">Result B</a></h2>
|
| 10 |
+
<div class="b_caption"><p>Snippet B</p></div>
|
| 11 |
+
</li>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<body>
|
| 4 |
+
<p>Your request looks suspicious. Please verify you are human to continue.</p>
|
| 5 |
+
</body>
|
| 6 |
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<body>
|
| 4 |
+
<div class="snippet">
|
| 5 |
+
<a href="https://example.org/brave-a"><span class="title">Brave Result A</span></a>
|
| 6 |
+
<p class="snippet-description">Brave snippet A</p>
|
| 7 |
+
</div>
|
| 8 |
+
<div class="snippet">
|
| 9 |
+
<a href="https://example.org/brave-b"><span class="title">Brave Result B</span></a>
|
| 10 |
+
<p class="snippet-description">Brave snippet B</p>
|
| 11 |
+
</div>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ops:world-patent-data": {
|
| 3 |
+
"exchange-documents": {
|
| 4 |
+
"exchange-document": {
|
| 5 |
+
"@country": "US",
|
| 6 |
+
"@doc-number": "11930446",
|
| 7 |
+
"bibliographic-data": {
|
| 8 |
+
"publication-reference": {
|
| 9 |
+
"document-id": [
|
| 10 |
+
{"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}}
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
"invention-title": {"@lang": "en", "$": "Widget apparatus"},
|
| 14 |
+
"patent-classifications": {
|
| 15 |
+
"patent-classification": [
|
| 16 |
+
{
|
| 17 |
+
"section": {"$": "G"},
|
| 18 |
+
"class": {"$": "06"},
|
| 19 |
+
"subclass": {"$": "F"},
|
| 20 |
+
"main-group": {"$": "17"},
|
| 21 |
+
"subgroup": {"$": "30"}
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"section": {"$": "H"},
|
| 25 |
+
"class": {"$": "04"},
|
| 26 |
+
"subclass": {"$": "L"},
|
| 27 |
+
"main-group": {"$": "9"},
|
| 28 |
+
"subgroup": {"$": "00"}
|
| 29 |
+
}
|
| 30 |
+
]
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"abstract": {"@lang": "en", "p": [{"$": "An apparatus comprising a widget."}]}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ops:world-patent-data": {
|
| 3 |
+
"ftxt:fulltext-documents": {
|
| 4 |
+
"ftxt:fulltext-document": {
|
| 5 |
+
"claims": {
|
| 6 |
+
"@lang": "en",
|
| 7 |
+
"claim": [
|
| 8 |
+
{"claim-text": {"$": "1. A widget comprising a gadget."}},
|
| 9 |
+
{"claim-text": {"$": "2. The widget of claim 1, further comprising a mechanism."}}
|
| 10 |
+
]
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ops:world-patent-data": {
|
| 3 |
+
"ftxt:fulltext-documents": {
|
| 4 |
+
"ftxt:fulltext-document": {
|
| 5 |
+
"description": {
|
| 6 |
+
"@lang": "en",
|
| 7 |
+
"p": [
|
| 8 |
+
{"$": "This invention relates to widgets."},
|
| 9 |
+
{"$": "The widget comprises a gadget and a mechanism."}
|
| 10 |
+
]
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fault": {
|
| 3 |
+
"faultstring": "SERVER.EntityNotFound",
|
| 4 |
+
"detail": {"errorcode": "SERVER.EntityNotFound"}
|
| 5 |
+
}
|
| 6 |
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ops:world-patent-data": {
|
| 3 |
+
"ops:biblio-search": {
|
| 4 |
+
"ops:search-result": {
|
| 5 |
+
"exchange-documents": [
|
| 6 |
+
{
|
| 7 |
+
"exchange-document": {
|
| 8 |
+
"@country": "US",
|
| 9 |
+
"@doc-number": "11930446",
|
| 10 |
+
"bibliographic-data": {
|
| 11 |
+
"publication-reference": {
|
| 12 |
+
"document-id": [
|
| 13 |
+
{"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}}
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
"invention-title": [
|
| 17 |
+
{"@lang": "en", "$": "Widget apparatus"},
|
| 18 |
+
{"@lang": "de", "$": "Widgetvorrichtung"}
|
| 19 |
+
]
|
| 20 |
+
},
|
| 21 |
+
"abstract": [
|
| 22 |
+
{"@lang": "en", "p": [{"$": "An apparatus comprising a widget."}]}
|
| 23 |
+
]
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"exchange-document": {
|
| 28 |
+
"@country": "EP",
|
| 29 |
+
"@doc-number": "4760514",
|
| 30 |
+
"bibliographic-data": {
|
| 31 |
+
"publication-reference": {
|
| 32 |
+
"document-id": [
|
| 33 |
+
{"@document-id-type": "epodoc", "doc-number": {"$": "EP4760514"}}
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
"invention-title": {"@lang": "en", "$": "Gadget system"}
|
| 37 |
+
},
|
| 38 |
+
"abstract": {"@lang": "en", "p": {"$": "A system comprising a gadget."}}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
]
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"access_token": "test-access-token-123",
|
| 3 |
+
"expires_in": 1199,
|
| 4 |
+
"token_type": "Bearer"
|
| 5 |
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta name="DC.title" content="Widget with improved gadget mechanism">
|
| 5 |
+
</head>
|
| 6 |
+
<body>
|
| 7 |
+
|
| 8 |
+
<div class="abstract">A widget comprising a gadget and a mechanism for improving widget performance.</div>
|
| 9 |
+
|
| 10 |
+
<section itemprop="description">
|
| 11 |
+
<div>TECHNICAL FIELD</div>
|
| 12 |
+
<div>This invention relates to widgets and gadgets for testing purposes.</div>
|
| 13 |
+
<div>BACKGROUND OF THE INVENTION</div>
|
| 14 |
+
<div>Prior art widgets suffered from several problems described herein.</div>
|
| 15 |
+
<div>SUMMARY OF THE INVENTION</div>
|
| 16 |
+
<div>The present invention solves the problems of the prior art by providing an improved widget.</div>
|
| 17 |
+
<div>DETAILED DESCRIPTION</div>
|
| 18 |
+
<div>The following describes example embodiments of the widget in detail.</div>
|
| 19 |
+
</section>
|
| 20 |
+
|
| 21 |
+
<section itemprop="claims">
|
| 22 |
+
<div class="claim">1. A widget comprising a gadget.</div>
|
| 23 |
+
<div class="claim">2. The widget of claim 1, further comprising a mechanism.</div>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<ul class="classifications">
|
| 27 |
+
<li><span>G06F</span>
|
| 28 |
+
<ul>
|
| 29 |
+
<li><span>G06F17/30</span> Database structures therefor</li>
|
| 30 |
+
<li><span>G06F17/50</span> Other database related</li>
|
| 31 |
+
</ul>
|
| 32 |
+
</li>
|
| 33 |
+
<li><span>H04L</span>
|
| 34 |
+
<ul>
|
| 35 |
+
<li><span>H04L9/00</span> Cryptographic mechanisms</li>
|
| 36 |
+
</ul>
|
| 37 |
+
</li>
|
| 38 |
+
</ul>
|
| 39 |
+
|
| 40 |
+
</body>
|
| 41 |
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
</head>
|
| 5 |
+
<body>
|
| 6 |
+
<p>This is not a valid Google Patents page (interstitial, error page, or markup change) - it has no meta[name=DC.title].</p>
|
| 7 |
+
</body>
|
| 8 |
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<body>
|
| 4 |
+
<search-result-item>
|
| 5 |
+
<span>US11930446B2</span>
|
| 6 |
+
<h3>Widget apparatus</h3>
|
| 7 |
+
<div class="abstract">An apparatus comprising a widget.</div>
|
| 8 |
+
</search-result-item>
|
| 9 |
+
<search-result-item>
|
| 10 |
+
<span>EP4760514A1</span>
|
| 11 |
+
<h4>Gadget system</h4>
|
| 12 |
+
<div class="result-snippet">A system comprising a gadget.</div>
|
| 13 |
+
</search-result-item>
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<body>
|
| 4 |
+
<div class="gs_ri">
|
| 5 |
+
<h3><a href="https://example.org/scholar-a">Scholar Paper A</a></h3>
|
| 6 |
+
<div class="gs_rs">Scholar snippet A</div>
|
| 7 |
+
</div>
|
| 8 |
+
<div class="gs_ri">
|
| 9 |
+
<h3><a href="https://example.org/scholar-b">Scholar Paper B</a></h3>
|
| 10 |
+
<div class="gs_rs">Scholar snippet B</div>
|
| 11 |
+
</div>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared test helpers: fixture loading and a fake-navigation Playwright
|
| 2 |
+
Browser wrapper used to characterize the scraper functions in `serp.py`
|
| 3 |
+
without hitting live search engines.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_fixture(*parts: str) -> str:
|
| 12 |
+
"""Read a text fixture from tests/fixtures/<parts...>."""
|
| 13 |
+
return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class _GotoPatchingContext:
|
| 17 |
+
"""Wraps a real Playwright BrowserContext so every Page it creates loads
|
| 18 |
+
fixture HTML via `set_content()` instead of navigating over the network.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, real_context, html: str):
|
| 22 |
+
self._real_context = real_context
|
| 23 |
+
self._html = html
|
| 24 |
+
|
| 25 |
+
async def new_page(self):
|
| 26 |
+
page = await self._real_context.new_page()
|
| 27 |
+
html = self._html
|
| 28 |
+
|
| 29 |
+
async def fake_goto(url, **kwargs):
|
| 30 |
+
# The scraper functions don't use the response object `goto`
|
| 31 |
+
# normally returns, so a plain None is a safe stand-in.
|
| 32 |
+
await page.set_content(html)
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
page.goto = fake_goto
|
| 36 |
+
return page
|
| 37 |
+
|
| 38 |
+
async def close(self):
|
| 39 |
+
await self._real_context.close()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class _GotoPatchingBrowser:
|
| 43 |
+
"""Wraps a real Playwright Browser the same way, so it can be passed
|
| 44 |
+
directly to `serp.py` functions in place of the real `pw_browser`.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(self, real_browser, html: str):
|
| 48 |
+
self._real_browser = real_browser
|
| 49 |
+
self._html = html
|
| 50 |
+
|
| 51 |
+
async def new_context(self, **kwargs):
|
| 52 |
+
real_context = await self._real_browser.new_context(**kwargs)
|
| 53 |
+
return _GotoPatchingContext(real_context, self._html)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def make_fixture_browser(real_browser, html: str) -> _GotoPatchingBrowser:
|
| 57 |
+
"""Build a Browser-like object for characterizing `serp.py`'s scraper
|
| 58 |
+
functions against fixture HTML.
|
| 59 |
+
|
| 60 |
+
Why this exists: each scraper (e.g. `query_bing_search`) registers its
|
| 61 |
+
own catch-all `page.route("**/*", ...)` resource blocker *inside* the
|
| 62 |
+
function, and that handler calls `route.continue_()` (not
|
| 63 |
+
`route.fallback()`) for the main document request. Playwright invokes
|
| 64 |
+
the most-recently-registered matching route first, so any route we
|
| 65 |
+
register on the page *before* calling the function never runs -
|
| 66 |
+
verified empirically, it's not a documented guarantee either way.
|
| 67 |
+
Patching `page.goto` itself, on a page created through this wrapper, is
|
| 68 |
+
the only reliable seam into these functions without touching their
|
| 69 |
+
source (see the code review this test suite followed from: the
|
| 70 |
+
fetch/navigate and parse logic aren't separated, which is exactly what
|
| 71 |
+
makes this workaround necessary).
|
| 72 |
+
"""
|
| 73 |
+
return _GotoPatchingBrowser(real_browser, html)
|
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
import pytest
|
| 6 |
+
import respx
|
| 7 |
+
|
| 8 |
+
import ops as ops_module
|
| 9 |
+
from ops import OPSNotConfigured, _OPSTokenManager, _ops_get, ops_scrap_patent, ops_search
|
| 10 |
+
|
| 11 |
+
FIXTURES = Path(__file__).parent / "fixtures" / "ops"
|
| 12 |
+
TOKEN_URL = "https://ops.epo.org/3.2/auth/accesstoken"
|
| 13 |
+
OPS_BASE = "https://ops.epo.org/3.2/rest-services"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def load_json(name: str):
|
| 17 |
+
return json.loads((FIXTURES / name).read_text())
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def mock_token_endpoint():
|
| 21 |
+
return respx.post(TOKEN_URL).mock(
|
| 22 |
+
return_value=httpx.Response(200, json=load_json("token_response.json"))
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def fresh_token_manager(monkeypatch):
|
| 28 |
+
"""A token manager with test credentials, swapped in for the module-level
|
| 29 |
+
singleton so `_ops_get`/`ops_search`/etc. (which reach for the module
|
| 30 |
+
global `token_manager` directly) pick it up.
|
| 31 |
+
"""
|
| 32 |
+
tm = _OPSTokenManager()
|
| 33 |
+
tm._key = "test-key"
|
| 34 |
+
tm._secret = "test-secret"
|
| 35 |
+
monkeypatch.setattr(ops_module, "token_manager", tm)
|
| 36 |
+
return tm
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class TestTokenManager:
|
| 40 |
+
async def test_not_configured_raises(self):
|
| 41 |
+
tm = _OPSTokenManager()
|
| 42 |
+
tm._key = None
|
| 43 |
+
tm._secret = None
|
| 44 |
+
async with httpx.AsyncClient() as client:
|
| 45 |
+
with pytest.raises(OPSNotConfigured):
|
| 46 |
+
await tm.get_token(client)
|
| 47 |
+
|
| 48 |
+
async def test_fetches_and_caches_the_token(self):
|
| 49 |
+
tm = _OPSTokenManager()
|
| 50 |
+
tm._key = "k"
|
| 51 |
+
tm._secret = "s"
|
| 52 |
+
with respx.mock:
|
| 53 |
+
route = mock_token_endpoint()
|
| 54 |
+
async with httpx.AsyncClient() as client:
|
| 55 |
+
token1 = await tm.get_token(client)
|
| 56 |
+
token2 = await tm.get_token(client)
|
| 57 |
+
|
| 58 |
+
assert token1 == "test-access-token-123"
|
| 59 |
+
assert token2 == "test-access-token-123"
|
| 60 |
+
assert route.call_count == 1
|
| 61 |
+
|
| 62 |
+
async def test_force_refresh_refetches_even_if_cached(self):
|
| 63 |
+
tm = _OPSTokenManager()
|
| 64 |
+
tm._key = "k"
|
| 65 |
+
tm._secret = "s"
|
| 66 |
+
with respx.mock:
|
| 67 |
+
route = mock_token_endpoint()
|
| 68 |
+
async with httpx.AsyncClient() as client:
|
| 69 |
+
await tm.get_token(client)
|
| 70 |
+
await tm.get_token(client, force=True)
|
| 71 |
+
|
| 72 |
+
assert route.call_count == 2
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class TestOpsGet:
|
| 76 |
+
async def test_retries_once_when_the_token_is_stale(self, fresh_token_manager):
|
| 77 |
+
with respx.mock:
|
| 78 |
+
mock_token_endpoint()
|
| 79 |
+
get_route = respx.get(f"{OPS_BASE}/some/path").mock(
|
| 80 |
+
side_effect=[
|
| 81 |
+
httpx.Response(401, text="Invalid access token."),
|
| 82 |
+
httpx.Response(200, json={"ok": True}),
|
| 83 |
+
]
|
| 84 |
+
)
|
| 85 |
+
async with httpx.AsyncClient() as client:
|
| 86 |
+
data = await _ops_get(client, "/some/path")
|
| 87 |
+
|
| 88 |
+
assert data == {"ok": True}
|
| 89 |
+
assert get_route.call_count == 2
|
| 90 |
+
|
| 91 |
+
async def test_propagates_non_token_errors(self, fresh_token_manager):
|
| 92 |
+
with respx.mock:
|
| 93 |
+
mock_token_endpoint()
|
| 94 |
+
respx.get(f"{OPS_BASE}/some/path").mock(return_value=httpx.Response(500, text="server error"))
|
| 95 |
+
async with httpx.AsyncClient() as client:
|
| 96 |
+
with pytest.raises(httpx.HTTPStatusError):
|
| 97 |
+
await _ops_get(client, "/some/path")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class TestOpsSearch:
|
| 101 |
+
async def test_maps_ops_search_results_to_serp_dicts(self, fresh_token_manager):
|
| 102 |
+
with respx.mock:
|
| 103 |
+
mock_token_endpoint()
|
| 104 |
+
respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
|
| 105 |
+
return_value=httpx.Response(200, json=load_json("search_response.json"))
|
| 106 |
+
)
|
| 107 |
+
async with httpx.AsyncClient() as client:
|
| 108 |
+
results = await ops_search(client, "widget", 10)
|
| 109 |
+
|
| 110 |
+
assert results == [
|
| 111 |
+
{
|
| 112 |
+
"title": "Widget apparatus",
|
| 113 |
+
"body": "An apparatus comprising a widget.",
|
| 114 |
+
"href": "https://worldwide.espacenet.com/patent/search?q=US11930446",
|
| 115 |
+
"id": "US11930446",
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"title": "Gadget system",
|
| 119 |
+
"body": "A system comprising a gadget.",
|
| 120 |
+
"href": "https://worldwide.espacenet.com/patent/search?q=EP4760514",
|
| 121 |
+
"id": "EP4760514",
|
| 122 |
+
},
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
async def test_returns_empty_list_on_404_not_found(self, fresh_token_manager):
|
| 126 |
+
with respx.mock:
|
| 127 |
+
mock_token_endpoint()
|
| 128 |
+
respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
|
| 129 |
+
return_value=httpx.Response(404, json=load_json("search_not_found_response.json"))
|
| 130 |
+
)
|
| 131 |
+
async with httpx.AsyncClient() as client:
|
| 132 |
+
results = await ops_search(client, "nonexistent widget xyz", 10)
|
| 133 |
+
|
| 134 |
+
assert results == []
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TestOpsScrapPatent:
|
| 138 |
+
async def test_assembles_a_full_patent_from_three_endpoints(self, fresh_token_manager):
|
| 139 |
+
number = "US11930446"
|
| 140 |
+
with respx.mock:
|
| 141 |
+
mock_token_endpoint()
|
| 142 |
+
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
|
| 143 |
+
return_value=httpx.Response(200, json=load_json("biblio_response.json"))
|
| 144 |
+
)
|
| 145 |
+
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
|
| 146 |
+
return_value=httpx.Response(200, json=load_json("claims_response.json"))
|
| 147 |
+
)
|
| 148 |
+
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
|
| 149 |
+
return_value=httpx.Response(200, json=load_json("description_response.json"))
|
| 150 |
+
)
|
| 151 |
+
async with httpx.AsyncClient() as client:
|
| 152 |
+
# Kind-code suffix ("B2") should be normalized away before
|
| 153 |
+
# any of the three requests go out.
|
| 154 |
+
result = await ops_scrap_patent(client, "US11930446B2")
|
| 155 |
+
|
| 156 |
+
assert result.title == "Widget apparatus"
|
| 157 |
+
assert result.abstract == "An apparatus comprising a widget."
|
| 158 |
+
assert result.claims == "1. A widget comprising a gadget.\n2. The widget of claim 1, further comprising a mechanism."
|
| 159 |
+
assert result.description == "This invention relates to widgets.\nThe widget comprises a gadget and a mechanism."
|
| 160 |
+
assert {c.code for c in result.classifications} == {"G06F17/30", "H04L9/00"}
|
| 161 |
+
|
| 162 |
+
async def test_missing_fulltext_degrades_to_none_instead_of_raising(self, fresh_token_manager):
|
| 163 |
+
number = "US11930446"
|
| 164 |
+
with respx.mock:
|
| 165 |
+
mock_token_endpoint()
|
| 166 |
+
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
|
| 167 |
+
return_value=httpx.Response(200, json=load_json("biblio_response.json"))
|
| 168 |
+
)
|
| 169 |
+
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
|
| 170 |
+
return_value=httpx.Response(404)
|
| 171 |
+
)
|
| 172 |
+
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
|
| 173 |
+
return_value=httpx.Response(404)
|
| 174 |
+
)
|
| 175 |
+
async with httpx.AsyncClient() as client:
|
| 176 |
+
result = await ops_scrap_patent(client, number)
|
| 177 |
+
|
| 178 |
+
assert result.title == "Widget apparatus"
|
| 179 |
+
assert result.claims is None
|
| 180 |
+
assert result.description is None
|
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for ops.py's pure JSON-walking helpers.
|
| 2 |
+
|
| 3 |
+
OPS's XML-to-JSON conversion is the trickiest part of this module: text
|
| 4 |
+
nodes become {"$": "..."}, attributes become "@name" keys, and a repeated
|
| 5 |
+
XML element becomes a list *unless there's only one*, in which case it stays
|
| 6 |
+
a bare dict. Every helper here has to handle both shapes; these tests pin
|
| 7 |
+
both for each one.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from ops import (
|
| 13 |
+
_as_list,
|
| 14 |
+
_text,
|
| 15 |
+
_pick_lang,
|
| 16 |
+
_epodoc_number,
|
| 17 |
+
_extract_title,
|
| 18 |
+
_extract_abstract,
|
| 19 |
+
_extract_classifications,
|
| 20 |
+
_fulltext_paragraphs,
|
| 21 |
+
_to_cql,
|
| 22 |
+
_normalize_epodoc,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestAsList:
|
| 27 |
+
def test_none_becomes_empty_list(self):
|
| 28 |
+
assert _as_list(None) == []
|
| 29 |
+
|
| 30 |
+
def test_single_dict_is_wrapped(self):
|
| 31 |
+
assert _as_list({"a": 1}) == [{"a": 1}]
|
| 32 |
+
|
| 33 |
+
def test_list_passes_through_unchanged(self):
|
| 34 |
+
assert _as_list([{"a": 1}, {"a": 2}]) == [{"a": 1}, {"a": 2}]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TestText:
|
| 38 |
+
def test_extracts_dollar_key(self):
|
| 39 |
+
assert _text({"$": "hello"}) == "hello"
|
| 40 |
+
|
| 41 |
+
def test_plain_string_passes_through(self):
|
| 42 |
+
assert _text("hello") == "hello"
|
| 43 |
+
|
| 44 |
+
def test_none_for_anything_else(self):
|
| 45 |
+
assert _text(None) is None
|
| 46 |
+
assert _text({"@lang": "en"}) is None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestPickLang:
|
| 50 |
+
def test_picks_requested_language_from_list(self):
|
| 51 |
+
nodes = [{"@lang": "de", "$": "Hallo"}, {"@lang": "en", "$": "Hello"}]
|
| 52 |
+
assert _pick_lang(nodes) == {"@lang": "en", "$": "Hello"}
|
| 53 |
+
|
| 54 |
+
def test_falls_back_to_first_when_language_missing(self):
|
| 55 |
+
nodes = [{"@lang": "de", "$": "Hallo"}, {"@lang": "fr", "$": "Bonjour"}]
|
| 56 |
+
assert _pick_lang(nodes) == {"@lang": "de", "$": "Hallo"}
|
| 57 |
+
|
| 58 |
+
def test_single_dict_not_wrapped_in_a_list_still_works(self):
|
| 59 |
+
node = {"@lang": "en", "$": "Hello"}
|
| 60 |
+
assert _pick_lang(node) == node
|
| 61 |
+
|
| 62 |
+
def test_empty_input_returns_none(self):
|
| 63 |
+
assert _pick_lang(None) is None
|
| 64 |
+
assert _pick_lang([]) is None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TestEpodocNumber:
|
| 68 |
+
def test_prefers_the_epodoc_document_id(self):
|
| 69 |
+
exchange_doc = {
|
| 70 |
+
"@country": "US",
|
| 71 |
+
"@doc-number": "11930446",
|
| 72 |
+
"bibliographic-data": {
|
| 73 |
+
"publication-reference": {
|
| 74 |
+
"document-id": [
|
| 75 |
+
{"@document-id-type": "docdb", "doc-number": {"$": "11930446"}},
|
| 76 |
+
{"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}},
|
| 77 |
+
]
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
}
|
| 81 |
+
assert _epodoc_number(exchange_doc) == "US11930446"
|
| 82 |
+
|
| 83 |
+
def test_falls_back_to_country_plus_doc_number(self):
|
| 84 |
+
exchange_doc = {"@country": "US", "@doc-number": "11930446", "bibliographic-data": {}}
|
| 85 |
+
assert _epodoc_number(exchange_doc) == "US11930446"
|
| 86 |
+
|
| 87 |
+
def test_none_when_nothing_available(self):
|
| 88 |
+
assert _epodoc_number({"bibliographic-data": {}}) is None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class TestExtractTitle:
|
| 92 |
+
def test_from_a_list_of_languages(self):
|
| 93 |
+
bd = {"invention-title": [{"@lang": "en", "$": "Widget"}, {"@lang": "de", "$": "Widget-DE"}]}
|
| 94 |
+
assert _extract_title(bd) == "Widget"
|
| 95 |
+
|
| 96 |
+
def test_from_a_single_bare_dict(self):
|
| 97 |
+
bd = {"invention-title": {"@lang": "en", "$": "Widget"}}
|
| 98 |
+
assert _extract_title(bd) == "Widget"
|
| 99 |
+
|
| 100 |
+
def test_none_when_missing(self):
|
| 101 |
+
assert _extract_title({}) is None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TestExtractAbstract:
|
| 105 |
+
def test_joins_paragraphs_from_a_list(self):
|
| 106 |
+
doc = {"abstract": [{"@lang": "en", "p": [{"$": "Part one."}, {"$": "Part two."}]}]}
|
| 107 |
+
assert _extract_abstract(doc) == "Part one. Part two."
|
| 108 |
+
|
| 109 |
+
def test_single_paragraph_not_wrapped_in_a_list(self):
|
| 110 |
+
doc = {"abstract": {"@lang": "en", "p": {"$": "Only paragraph."}}}
|
| 111 |
+
assert _extract_abstract(doc) == "Only paragraph."
|
| 112 |
+
|
| 113 |
+
def test_none_when_missing(self):
|
| 114 |
+
assert _extract_abstract({}) is None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class TestExtractClassifications:
|
| 118 |
+
def test_builds_codes_from_the_five_part_symbol(self):
|
| 119 |
+
bd = {
|
| 120 |
+
"patent-classifications": {
|
| 121 |
+
"patent-classification": [
|
| 122 |
+
{
|
| 123 |
+
"section": {"$": "G"},
|
| 124 |
+
"class": {"$": "06"},
|
| 125 |
+
"subclass": {"$": "F"},
|
| 126 |
+
"main-group": {"$": "17"},
|
| 127 |
+
"subgroup": {"$": "30"},
|
| 128 |
+
}
|
| 129 |
+
]
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
codes = _extract_classifications(bd)
|
| 133 |
+
assert len(codes) == 1
|
| 134 |
+
assert codes[0].code == "G06F17/30"
|
| 135 |
+
assert codes[0].description == ""
|
| 136 |
+
|
| 137 |
+
def test_dedupes_and_skips_incomplete_entries(self):
|
| 138 |
+
bd = {
|
| 139 |
+
"patent-classifications": {
|
| 140 |
+
"patent-classification": [
|
| 141 |
+
{"section": {"$": "G"}, "class": {"$": "06"}, "subclass": {"$": "F"}, "main-group": {"$": "17"}, "subgroup": {"$": "30"}},
|
| 142 |
+
{"section": {"$": "G"}, "class": {"$": "06"}, "subclass": {"$": "F"}, "main-group": {"$": "17"}, "subgroup": {"$": "30"}},
|
| 143 |
+
{"section": {"$": "H"}}, # incomplete - no class/subclass/main-group
|
| 144 |
+
]
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
codes = _extract_classifications(bd)
|
| 148 |
+
assert [c.code for c in codes] == ["G06F17/30"]
|
| 149 |
+
|
| 150 |
+
def test_none_when_no_classifications(self):
|
| 151 |
+
assert _extract_classifications({}) is None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class TestFulltextParagraphs:
|
| 155 |
+
def test_claims_join_claim_text_lines(self):
|
| 156 |
+
data = {
|
| 157 |
+
"ops:world-patent-data": {
|
| 158 |
+
"ftxt:fulltext-documents": {
|
| 159 |
+
"ftxt:fulltext-document": {
|
| 160 |
+
"claims": {
|
| 161 |
+
"@lang": "en",
|
| 162 |
+
"claim": [
|
| 163 |
+
{"claim-text": {"$": "1. A widget."}},
|
| 164 |
+
{"claim-text": [{"$": "2. A gadget,"}, {"$": "further comprising a mechanism."}]},
|
| 165 |
+
],
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
assert _fulltext_paragraphs(data, "claims") == (
|
| 172 |
+
"1. A widget.\n2. A gadget,\nfurther comprising a mechanism."
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
def test_description_joins_paragraphs(self):
|
| 176 |
+
data = {
|
| 177 |
+
"ops:world-patent-data": {
|
| 178 |
+
"ftxt:fulltext-documents": {
|
| 179 |
+
"ftxt:fulltext-document": {
|
| 180 |
+
"description": {"@lang": "en", "p": [{"$": "Para one."}, {"$": "Para two."}]}
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
assert _fulltext_paragraphs(data, "description") == "Para one.\nPara two."
|
| 186 |
+
|
| 187 |
+
def test_none_when_document_missing(self):
|
| 188 |
+
assert _fulltext_paragraphs({"ops:world-patent-data": {}}, "claims") is None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@pytest.mark.parametrize(
|
| 192 |
+
"raw, expected",
|
| 193 |
+
[
|
| 194 |
+
("widget", 'txt all "widget"'),
|
| 195 |
+
("agentic ai", 'txt all "agentic ai"'),
|
| 196 |
+
('pn = "US1234"', 'pn = "US1234"'),
|
| 197 |
+
("", ""),
|
| 198 |
+
('has "quotes"', 'txt all "has quotes"'),
|
| 199 |
+
],
|
| 200 |
+
)
|
| 201 |
+
def test_to_cql(raw, expected):
|
| 202 |
+
assert _to_cql(raw) == expected
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@pytest.mark.parametrize(
|
| 206 |
+
"raw, expected",
|
| 207 |
+
[
|
| 208 |
+
("US11930446B2", "US11930446"),
|
| 209 |
+
("EP4760514A1", "EP4760514"),
|
| 210 |
+
("US 11930446 B2", "US11930446"),
|
| 211 |
+
("US11930446", "US11930446"),
|
| 212 |
+
],
|
| 213 |
+
)
|
| 214 |
+
def test_normalize_epodoc(raw, expected):
|
| 215 |
+
assert _normalize_epodoc(raw) == expected
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import pytest
|
| 3 |
+
import respx
|
| 4 |
+
|
| 5 |
+
from helpers import load_fixture
|
| 6 |
+
from scrap import scrap_patent_async, scrap_patent_bulk_async
|
| 7 |
+
|
| 8 |
+
FULL_PATENT_HTML = load_fixture("patents", "full_patent.html")
|
| 9 |
+
MISSING_TITLE_HTML = load_fixture("patents", "missing_title.html")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def test_scrap_patent_async_extracts_all_fields():
|
| 13 |
+
url = "https://patents.google.com/patent/US11930446B2/en"
|
| 14 |
+
with respx.mock:
|
| 15 |
+
respx.get(url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
|
| 16 |
+
async with httpx.AsyncClient() as client:
|
| 17 |
+
result = await scrap_patent_async(client, url)
|
| 18 |
+
|
| 19 |
+
assert result.title == "Widget with improved gadget mechanism"
|
| 20 |
+
assert result.abstract == "A widget comprising a gadget and a mechanism for improving widget performance."
|
| 21 |
+
assert result.field_of_invention == "This invention relates to widgets and gadgets for testing purposes."
|
| 22 |
+
assert result.background == "Prior art widgets suffered from several problems described herein."
|
| 23 |
+
assert "1. A widget comprising a gadget." in result.claims
|
| 24 |
+
assert "2. The widget of claim 1, further comprising a mechanism." in result.claims
|
| 25 |
+
|
| 26 |
+
codes = {c.code: c.description for c in result.classifications}
|
| 27 |
+
assert codes == {
|
| 28 |
+
"G06F17/30": "Database structures therefor",
|
| 29 |
+
"G06F17/50": "Other database related",
|
| 30 |
+
"H04L9/00": "Cryptographic mechanisms",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def test_scrap_patent_async_raises_when_page_has_no_title():
|
| 35 |
+
"""Pins the documented behavior at scrap.py: an unexpected page layout
|
| 36 |
+
(interstitial, non-patent page, markup change) raises ValueError rather
|
| 37 |
+
than crashing on `None.get(...)`, so callers can treat it as a failed
|
| 38 |
+
scrape.
|
| 39 |
+
"""
|
| 40 |
+
url = "https://patents.google.com/patent/BOGUS/en"
|
| 41 |
+
with respx.mock:
|
| 42 |
+
respx.get(url).mock(return_value=httpx.Response(200, text=MISSING_TITLE_HTML))
|
| 43 |
+
async with httpx.AsyncClient() as client:
|
| 44 |
+
with pytest.raises(ValueError):
|
| 45 |
+
await scrap_patent_async(client, url)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def test_scrap_patent_async_raises_on_http_error():
|
| 49 |
+
url = "https://patents.google.com/patent/US00000000/en"
|
| 50 |
+
with respx.mock:
|
| 51 |
+
respx.get(url).mock(return_value=httpx.Response(404))
|
| 52 |
+
async with httpx.AsyncClient() as client:
|
| 53 |
+
with pytest.raises(httpx.HTTPStatusError):
|
| 54 |
+
await scrap_patent_async(client, url)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_scrap_patent_bulk_async_separates_successes_from_failures():
|
| 58 |
+
ok_url = "https://patents.google.com/patent/US_OK/en"
|
| 59 |
+
fail_url = "https://patents.google.com/patent/US_FAIL/en"
|
| 60 |
+
with respx.mock:
|
| 61 |
+
respx.get(ok_url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
|
| 62 |
+
respx.get(fail_url).mock(return_value=httpx.Response(404))
|
| 63 |
+
async with httpx.AsyncClient() as client:
|
| 64 |
+
result = await scrap_patent_bulk_async(client, ["US_OK", "US_FAIL"])
|
| 65 |
+
|
| 66 |
+
assert len(result.patents) == 1
|
| 67 |
+
assert result.patents[0].title == "Widget with improved gadget mechanism"
|
| 68 |
+
assert result.failed_ids == ["US_FAIL"]
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import respx
|
| 3 |
+
|
| 4 |
+
from helpers import load_fixture
|
| 5 |
+
from serp import query_arxiv
|
| 6 |
+
|
| 7 |
+
ARXIV_XML = load_fixture("arxiv_response.xml")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def test_query_arxiv_parses_the_atom_feed():
|
| 11 |
+
with respx.mock:
|
| 12 |
+
respx.get("https://export.arxiv.org/api/query").mock(
|
| 13 |
+
return_value=httpx.Response(200, text=ARXIV_XML, headers={"content-type": "application/atom+xml"})
|
| 14 |
+
)
|
| 15 |
+
async with httpx.AsyncClient() as client:
|
| 16 |
+
results = await query_arxiv(client, "widgets", 2)
|
| 17 |
+
|
| 18 |
+
assert results == [
|
| 19 |
+
{
|
| 20 |
+
"title": "A Study of Widgets",
|
| 21 |
+
"href": "http://arxiv.org/pdf/2401.00001v1",
|
| 22 |
+
"body": "This paper studies widgets in depth.",
|
| 23 |
+
"id": "http://arxiv.org/abs/2401.00001v1",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"title": "Gadgets and Their Applications",
|
| 27 |
+
"href": "http://arxiv.org/pdf/2401.00002v1",
|
| 28 |
+
"body": "An overview of gadgets.",
|
| 29 |
+
"id": "http://arxiv.org/abs/2401.00002v1",
|
| 30 |
+
},
|
| 31 |
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
import serp
|
| 4 |
+
from serp import DuckDuckGoBlockedException, query_ddg_search
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class _FakeDDGS:
|
| 8 |
+
def __init__(self, results):
|
| 9 |
+
self._results = results
|
| 10 |
+
|
| 11 |
+
def text(self, q, max_results=10):
|
| 12 |
+
return iter(self._results)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def test_maps_duckduckgo_results_to_serp_dicts(monkeypatch):
|
| 16 |
+
monkeypatch.setattr(
|
| 17 |
+
serp,
|
| 18 |
+
"DDGS",
|
| 19 |
+
lambda: _FakeDDGS(
|
| 20 |
+
[
|
| 21 |
+
{"title": "A", "body": "Body A", "href": "https://a.example"},
|
| 22 |
+
{"title": "B", "body": "Body B", "href": "https://b.example"},
|
| 23 |
+
]
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
results = await query_ddg_search("test query", 10)
|
| 28 |
+
|
| 29 |
+
assert results == [
|
| 30 |
+
{"title": "A", "body": "Body A", "href": "https://a.example"},
|
| 31 |
+
{"title": "B", "body": "Body B", "href": "https://b.example"},
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def test_raises_blocked_exception_on_empty_results(monkeypatch):
|
| 36 |
+
"""Pins the fix from commit d341a7a: duckduckgo_search doesn't always
|
| 37 |
+
raise when it's rate-limited/blocked - it can just parse zero results
|
| 38 |
+
from the anti-bot page. An empty list must be treated as a failure so
|
| 39 |
+
the `/serp/search` fallback chain moves on to the next backend.
|
| 40 |
+
"""
|
| 41 |
+
monkeypatch.setattr(serp, "DDGS", lambda: _FakeDDGS([]))
|
| 42 |
+
|
| 43 |
+
with pytest.raises(DuckDuckGoBlockedException):
|
| 44 |
+
await query_ddg_search("test query", 10)
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from serp import SerpQuery
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.mark.parametrize(
|
| 7 |
+
"raw, expected",
|
| 8 |
+
[
|
| 9 |
+
(10, 10),
|
| 10 |
+
(1, 1),
|
| 11 |
+
(100, 100),
|
| 12 |
+
(0, 1),
|
| 13 |
+
(-5, 1),
|
| 14 |
+
(500, 100),
|
| 15 |
+
(101, 100),
|
| 16 |
+
],
|
| 17 |
+
)
|
| 18 |
+
def test_n_results_is_clamped_to_1_100(raw, expected):
|
| 19 |
+
q = SerpQuery(queries=["test"], n_results=raw)
|
| 20 |
+
assert q.n_results == expected
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_n_results_defaults_to_10():
|
| 24 |
+
q = SerpQuery(queries=["test"])
|
| 25 |
+
assert q.n_results == 10
|
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Characterization tests for the Playwright-driven scrapers in serp.py.
|
| 2 |
+
|
| 3 |
+
These run against a real headless Chromium (same engine production uses) so
|
| 4 |
+
that DOM/CSS-selector behavior is genuinely exercised, but the target page is
|
| 5 |
+
served locally from a fixture instead of navigating to the live site - see
|
| 6 |
+
`helpers.make_fixture_browser` for how, and why a simpler
|
| 7 |
+
`page.route()`-based approach doesn't work here.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from helpers import load_fixture, make_fixture_browser
|
| 13 |
+
from serp import (
|
| 14 |
+
BraveSearchBlockedException,
|
| 15 |
+
BrowserUnavailableError,
|
| 16 |
+
playwright_open_page,
|
| 17 |
+
query_bing_search,
|
| 18 |
+
query_brave_search,
|
| 19 |
+
query_google_patents,
|
| 20 |
+
query_google_scholar,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def test_bing_search_parses_results(browser):
|
| 25 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
|
| 26 |
+
|
| 27 |
+
results = await query_bing_search(fixture_browser, "test query", 10)
|
| 28 |
+
|
| 29 |
+
assert results == [
|
| 30 |
+
{"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
|
| 31 |
+
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def test_brave_search_parses_results(browser):
|
| 36 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("brave_results.html"))
|
| 37 |
+
|
| 38 |
+
results = await query_brave_search(fixture_browser, "test query", 10)
|
| 39 |
+
|
| 40 |
+
assert results == [
|
| 41 |
+
{"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
|
| 42 |
+
{"title": "Brave Result B", "body": "Brave snippet B", "href": "https://example.org/brave-b"},
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_brave_search_detects_the_suspicious_block_page(browser):
|
| 47 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("brave_blocked.html"))
|
| 48 |
+
|
| 49 |
+
with pytest.raises(BraveSearchBlockedException):
|
| 50 |
+
await query_brave_search(fixture_browser, "test query", 10)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def test_google_scholar_parses_results(browser):
|
| 54 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("scholar_results.html"))
|
| 55 |
+
|
| 56 |
+
results = await query_google_scholar(fixture_browser, "test query", 10)
|
| 57 |
+
|
| 58 |
+
assert results == [
|
| 59 |
+
{"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
|
| 60 |
+
{"title": "Scholar Paper B", "body": "Scholar snippet B", "href": "https://example.org/scholar-b"},
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def test_google_patents_parses_results(browser):
|
| 65 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("patents", "search_results.html"))
|
| 66 |
+
|
| 67 |
+
results = await query_google_patents(fixture_browser, "test query", 10)
|
| 68 |
+
|
| 69 |
+
assert results == [
|
| 70 |
+
{
|
| 71 |
+
"id": "US11930446B2",
|
| 72 |
+
"href": "https://patents.google.com/patent/US11930446B2/en",
|
| 73 |
+
"title": "Widget apparatus",
|
| 74 |
+
"body": "An apparatus comprising a widget.",
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "EP4760514A1",
|
| 78 |
+
"href": "https://patents.google.com/patent/EP4760514A1/en",
|
| 79 |
+
"title": "Gadget system",
|
| 80 |
+
"body": "A system comprising a gadget.",
|
| 81 |
+
},
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def test_playwright_open_page_raises_when_browser_unavailable():
|
| 86 |
+
with pytest.raises(BrowserUnavailableError):
|
| 87 |
+
async with playwright_open_page(None):
|
| 88 |
+
pass
|
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from serp import SerpQuery
|
| 4 |
+
from utils import log_gathered_exceptions
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_logs_only_the_exceptions_with_their_query(caplog):
|
| 8 |
+
params = SerpQuery(queries=["q1", "q2", "q3"])
|
| 9 |
+
results = ["ok result", ValueError("boom"), "another ok"]
|
| 10 |
+
|
| 11 |
+
with caplog.at_level(logging.WARNING):
|
| 12 |
+
log_gathered_exceptions(results, "test context", params)
|
| 13 |
+
|
| 14 |
+
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
| 15 |
+
assert len(warnings) == 1
|
| 16 |
+
assert "q2" in warnings[0].message
|
| 17 |
+
assert "boom" in warnings[0].message
|
| 18 |
+
assert "test context" in warnings[0].message
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_logs_nothing_when_all_results_succeeded(caplog):
|
| 22 |
+
params = SerpQuery(queries=["q1", "q2"])
|
| 23 |
+
results = ["ok", "also ok"]
|
| 24 |
+
|
| 25 |
+
with caplog.at_level(logging.WARNING):
|
| 26 |
+
log_gathered_exceptions(results, "test context", params)
|
| 27 |
+
|
| 28 |
+
assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_uses_the_asyncio_logger_not_an_app_logger(caplog):
|
| 32 |
+
"""Characterizes a real bug (flagged in review): `utils.py` imports
|
| 33 |
+
`logger` from `asyncio.log` instead of using `logging.getLogger(__name__)`,
|
| 34 |
+
so these warnings are misattributed to the 'asyncio' logging namespace
|
| 35 |
+
instead of the app's own. Pinned here so the fix (giving `utils.py` its
|
| 36 |
+
own logger) shows up as an intentional assertion change, not a silent
|
| 37 |
+
behavior drift.
|
| 38 |
+
"""
|
| 39 |
+
params = SerpQuery(queries=["q1"])
|
| 40 |
+
with caplog.at_level(logging.WARNING):
|
| 41 |
+
log_gathered_exceptions([ValueError("boom")], "ctx", params)
|
| 42 |
+
|
| 43 |
+
assert caplog.records[0].name == "asyncio"
|