Spaces:
Running
Running
Add test harness and fix TDD/architecture review findings (#3)
Browse files- .github/workflows/deploy-to-hf.yml +21 -0
- README.md +13 -0
- app.py +35 -80
- pytest.ini +3 -0
- requirements-dev.txt +7 -0
- scrap.py +12 -1
- serp.py +140 -134
- tests/conftest.py +61 -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_app.py +159 -0
- tests/test_ops_client.py +180 -0
- tests/test_ops_parsing.py +215 -0
- tests/test_scrap.py +91 -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 +109 -0
- tests/test_utils.py +40 -0
- utils.py +4 -1
.github/workflows/deploy-to-hf.yml
CHANGED
|
@@ -7,7 +7,28 @@ on:
|
|
| 7 |
workflow_dispatch:
|
| 8 |
|
| 9 |
jobs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
deploy:
|
|
|
|
| 11 |
runs-on: ubuntu-latest
|
| 12 |
steps:
|
| 13 |
- name: Checkout
|
|
|
|
| 7 |
workflow_dispatch:
|
| 8 |
|
| 9 |
jobs:
|
| 10 |
+
test:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout
|
| 14 |
+
uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- name: Set up Python
|
| 17 |
+
uses: actions/setup-python@v5
|
| 18 |
+
with:
|
| 19 |
+
python-version: "3.11"
|
| 20 |
+
|
| 21 |
+
- name: Install dependencies
|
| 22 |
+
run: pip install -r requirements-dev.txt
|
| 23 |
+
|
| 24 |
+
- name: Install Playwright's Chromium
|
| 25 |
+
run: playwright install --with-deps chromium
|
| 26 |
+
|
| 27 |
+
- name: Run tests
|
| 28 |
+
run: pytest
|
| 29 |
+
|
| 30 |
deploy:
|
| 31 |
+
needs: test
|
| 32 |
runs-on: ubuntu-latest
|
| 33 |
steps:
|
| 34 |
- name: Checkout
|
README.md
CHANGED
|
@@ -13,3 +13,16 @@ pinned: true
|
|
| 13 |
# `SERPent`
|
| 14 |
|
| 15 |
`SERPent` provides a SERP / scrapping API for use by AI agents / projects.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# `SERPent`
|
| 14 |
|
| 15 |
`SERPent` provides a SERP / scrapping API for use by AI agents / projects.
|
| 16 |
+
|
| 17 |
+
## Running tests
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
pip install -r requirements.txt -r requirements-dev.txt
|
| 21 |
+
playwright install chromium # if not already present on the machine
|
| 22 |
+
pytest
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
|
| 26 |
+
scrapers against local fixture HTML instead of live search engines - see
|
| 27 |
+
`tests/helpers.py` for how - so it runs fully offline and doesn't depend on,
|
| 28 |
+
or risk tripping, any site's anti-bot measures.
|
app.py
CHANGED
|
@@ -104,40 +104,46 @@ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
|
|
| 104 |
# ===================== Search endpoints =====================
|
| 105 |
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
log_gathered_exceptions(results, "google scholar search", params)
|
| 113 |
-
|
| 114 |
-
# Filter out exceptions and flatten the results
|
| 115 |
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 116 |
flattened_results = [
|
| 117 |
item for sublist in filtered_results for item in sublist]
|
| 118 |
|
| 119 |
-
# all queries failed, return the last exception
|
| 120 |
if len(filtered_results) == 0:
|
| 121 |
return SerpResults(results=[], error=str(results[-1]))
|
| 122 |
|
| 123 |
return SerpResults(results=flattened_results, error=None)
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
@serp_router.post("/search_arxiv")
|
| 127 |
async def search_arxiv(params: SerpQuery):
|
| 128 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 129 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 134 |
-
flattened_results = [
|
| 135 |
-
item for sublist in filtered_results for item in sublist]
|
| 136 |
-
|
| 137 |
-
if len(filtered_results) == 0:
|
| 138 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 139 |
-
|
| 140 |
-
return SerpResults(results=flattened_results, error=None)
|
| 141 |
|
| 142 |
|
| 143 |
@serp_router.post("/search_patents")
|
|
@@ -162,73 +168,31 @@ async def search_patents(params: SerpQuery) -> SerpResults:
|
|
| 162 |
except Exception as e:
|
| 163 |
logging.warning(f"OPS fallback failed: {e}")
|
| 164 |
|
| 165 |
-
|
| 166 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 167 |
-
flattened_results = [
|
| 168 |
-
item for sublist in filtered_results for item in sublist]
|
| 169 |
-
|
| 170 |
-
# all queries failed, return the last exception
|
| 171 |
-
if len(filtered_results) == 0:
|
| 172 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 173 |
-
|
| 174 |
-
return SerpResults(results=flattened_results, error=None)
|
| 175 |
|
| 176 |
|
| 177 |
@serp_router.post("/search_brave")
|
| 178 |
async def search_brave(params: SerpQuery) -> SerpResults:
|
| 179 |
"""Searches brave search for the specified queries and returns the found documents."""
|
| 180 |
logging.info(f"Searching Brave Search for queries: {params.queries}")
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
# Filter out exceptions and flatten the results
|
| 185 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 186 |
-
flattened_results = [
|
| 187 |
-
item for sublist in filtered_results for item in sublist]
|
| 188 |
-
|
| 189 |
-
# all queries failed, return the last exception
|
| 190 |
-
if len(filtered_results) == 0:
|
| 191 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 192 |
-
|
| 193 |
-
return SerpResults(results=flattened_results, error=None)
|
| 194 |
|
| 195 |
|
| 196 |
@serp_router.post("/search_bing")
|
| 197 |
async def search_bing(params: SerpQuery) -> SerpResults:
|
| 198 |
"""Searches Bing search for the specified queries and returns the found documents."""
|
| 199 |
logging.info(f"Searching Bing Search for queries: {params.queries}")
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
# Filter out exceptions and flatten the results
|
| 204 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 205 |
-
flattened_results = [
|
| 206 |
-
item for sublist in filtered_results for item in sublist]
|
| 207 |
-
|
| 208 |
-
# all queries failed, return the last exception
|
| 209 |
-
if len(filtered_results) == 0:
|
| 210 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 211 |
-
|
| 212 |
-
return SerpResults(results=flattened_results, error=None)
|
| 213 |
|
| 214 |
|
| 215 |
@serp_router.post("/search_duck")
|
| 216 |
async def search_duck(params: SerpQuery) -> SerpResults:
|
| 217 |
"""Searches duckduckgo for the specified queries and returns the found documents"""
|
| 218 |
logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
# Filter out exceptions and flatten the results
|
| 223 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 224 |
-
flattened_results = [
|
| 225 |
-
item for sublist in filtered_results for item in sublist]
|
| 226 |
-
|
| 227 |
-
# all queries failed, return the last exception
|
| 228 |
-
if len(filtered_results) == 0:
|
| 229 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 230 |
-
|
| 231 |
-
return SerpResults(results=flattened_results, error=None)
|
| 232 |
|
| 233 |
|
| 234 |
async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
|
@@ -327,17 +291,8 @@ async def scrap_patents(params: ScrapPatentsRequest) -> PatentScrapBulkResponse:
|
|
| 327 |
async def ops_keyword_search(params: SerpQuery) -> SerpResults:
|
| 328 |
"""Keyword-searches patents via the official EPO OPS API."""
|
| 329 |
logging.info(f"Searching EPO OPS for queries: {params.queries}")
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 334 |
-
flattened_results = [
|
| 335 |
-
item for sublist in filtered_results for item in sublist]
|
| 336 |
-
|
| 337 |
-
if len(filtered_results) == 0:
|
| 338 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 339 |
-
|
| 340 |
-
return SerpResults(results=flattened_results, error=None)
|
| 341 |
|
| 342 |
|
| 343 |
@ops_router.get("/scrap_patent/{patent_id}")
|
|
|
|
| 104 |
# ===================== Search endpoints =====================
|
| 105 |
|
| 106 |
|
| 107 |
+
def _shape_serp_results(results: list) -> SerpResults:
|
| 108 |
+
"""Flatten a list of per-query result-lists (or Exceptions, from a
|
| 109 |
+
`return_exceptions=True` gather) into one SerpResults, surfacing the
|
| 110 |
+
last error only when every query failed.
|
| 111 |
+
"""
|
|
|
|
|
|
|
|
|
|
| 112 |
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 113 |
flattened_results = [
|
| 114 |
item for sublist in filtered_results for item in sublist]
|
| 115 |
|
|
|
|
| 116 |
if len(filtered_results) == 0:
|
| 117 |
return SerpResults(results=[], error=str(results[-1]))
|
| 118 |
|
| 119 |
return SerpResults(results=flattened_results, error=None)
|
| 120 |
|
| 121 |
|
| 122 |
+
async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
|
| 123 |
+
"""Run `fn(query, n_results)` concurrently for every query in `params`,
|
| 124 |
+
log any failures, and shape the result the way every simple /serp and
|
| 125 |
+
/ops search endpoint below expects.
|
| 126 |
+
"""
|
| 127 |
+
results = await asyncio.gather(
|
| 128 |
+
*[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
|
| 129 |
+
log_gathered_exceptions(results, context, params)
|
| 130 |
+
return _shape_serp_results(results)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@serp_router.post("/search_scholar")
|
| 134 |
+
async def search_google_scholar(params: SerpQuery):
|
| 135 |
+
"""Queries google scholar for the specified query"""
|
| 136 |
+
logging.info(f"Searching Google Scholar for queries: {params.queries}")
|
| 137 |
+
return await _run_serp_queries(
|
| 138 |
+
lambda q, n: query_google_scholar(pw_browser, q, n), params, "google scholar search")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
@serp_router.post("/search_arxiv")
|
| 142 |
async def search_arxiv(params: SerpQuery):
|
| 143 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 144 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 145 |
+
return await _run_serp_queries(
|
| 146 |
+
lambda q, n: query_arxiv(httpx_client, q, n), params, "arxiv search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
@serp_router.post("/search_patents")
|
|
|
|
| 168 |
except Exception as e:
|
| 169 |
logging.warning(f"OPS fallback failed: {e}")
|
| 170 |
|
| 171 |
+
return _shape_serp_results(results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
@serp_router.post("/search_brave")
|
| 175 |
async def search_brave(params: SerpQuery) -> SerpResults:
|
| 176 |
"""Searches brave search for the specified queries and returns the found documents."""
|
| 177 |
logging.info(f"Searching Brave Search for queries: {params.queries}")
|
| 178 |
+
return await _run_serp_queries(
|
| 179 |
+
lambda q, n: query_brave_search(pw_browser, q, n), params, "brave search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@serp_router.post("/search_bing")
|
| 183 |
async def search_bing(params: SerpQuery) -> SerpResults:
|
| 184 |
"""Searches Bing search for the specified queries and returns the found documents."""
|
| 185 |
logging.info(f"Searching Bing Search for queries: {params.queries}")
|
| 186 |
+
return await _run_serp_queries(
|
| 187 |
+
lambda q, n: query_bing_search(pw_browser, q, n), params, "bing search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
@serp_router.post("/search_duck")
|
| 191 |
async def search_duck(params: SerpQuery) -> SerpResults:
|
| 192 |
"""Searches duckduckgo for the specified queries and returns the found documents"""
|
| 193 |
logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
|
| 194 |
+
return await _run_serp_queries(
|
| 195 |
+
lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
|
| 198 |
async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
|
|
|
| 291 |
async def ops_keyword_search(params: SerpQuery) -> SerpResults:
|
| 292 |
"""Keyword-searches patents via the official EPO OPS API."""
|
| 293 |
logging.info(f"Searching EPO OPS for queries: {params.queries}")
|
| 294 |
+
return await _run_serp_queries(
|
| 295 |
+
lambda q, n: ops_search(httpx_client, q, n), params, "OPS search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
|
| 298 |
@ops_router.get("/scrap_patent/{patent_id}")
|
pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
asyncio_mode = auto
|
| 3 |
+
testpaths = tests
|
requirements-dev.txt
ADDED
|
@@ -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
|
scrap.py
CHANGED
|
@@ -38,7 +38,18 @@ async def scrap_patent_async(client: AsyncClient, patent_url: str) -> PatentScra
|
|
| 38 |
response = await client.get(patent_url, headers=headers)
|
| 39 |
response.raise_for_status()
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# Abstract
|
| 44 |
abstract_div = soup.find("div", {"class": "abstract"})
|
|
|
|
| 38 |
response = await client.get(patent_url, headers=headers)
|
| 39 |
response.raise_for_status()
|
| 40 |
|
| 41 |
+
return parse_patent_html(response.text, patent_url)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse_patent_html(html: str, patent_url: str) -> PatentScrapResult:
|
| 45 |
+
"""Parse a Google Patents patent page into a PatentScrapResult.
|
| 46 |
+
|
| 47 |
+
Pure function of the page's HTML - no network I/O - so it can be tested
|
| 48 |
+
directly against saved/fixture HTML without mocking a client.
|
| 49 |
+
`patent_url` is only used to identify the page in the error message
|
| 50 |
+
below.
|
| 51 |
+
"""
|
| 52 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 53 |
|
| 54 |
# Abstract
|
| 55 |
abstract_div = soup.find("div", {"class": "abstract"})
|
serp.py
CHANGED
|
@@ -82,187 +82,193 @@ async def playwright_open_page(browser: Optional[Browser]):
|
|
| 82 |
await context.close()
|
| 83 |
|
| 84 |
|
| 85 |
-
async def
|
| 86 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
async with playwright_open_page(browser) as page:
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
|
| 99 |
await page.goto(url)
|
| 100 |
|
| 101 |
-
await
|
| 102 |
|
| 103 |
-
items = await page.locator("div.gs_ri").all()
|
| 104 |
-
results = []
|
| 105 |
-
for item in items[:n_results]:
|
| 106 |
-
title = await item.locator("h3").inner_text(timeout=1000)
|
| 107 |
-
body = await item.locator("div.gs_rs").inner_text(timeout=1000)
|
| 108 |
-
href = await item.locator("h3 > a").get_attribute("href")
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
"body": body,
|
| 113 |
-
"href": href
|
| 114 |
-
})
|
| 115 |
|
| 116 |
-
return results
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
|
| 123 |
-
PATENT_ID_REGEX = r"\b[A-Z]{2}\d{6,}(?:[A-Z]\d?)?\b"
|
| 124 |
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
async def _block_resources(route, request):
|
| 128 |
-
if request.resource_type in ["stylesheet", "image"]:
|
| 129 |
-
await route.abort()
|
| 130 |
-
else:
|
| 131 |
-
await route.continue_()
|
| 132 |
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
|
| 136 |
await page.goto(url)
|
| 137 |
|
| 138 |
-
|
| 139 |
-
# This ensures the page has loaded enough to start scraping
|
| 140 |
-
await page.wait_for_function(
|
| 141 |
-
f"""() => document.querySelectorAll('search-result-item').length >= 1""",
|
| 142 |
-
timeout=30_000
|
| 143 |
-
)
|
| 144 |
-
|
| 145 |
-
items = await page.locator("search-result-item").all()
|
| 146 |
-
results = []
|
| 147 |
-
for item in items:
|
| 148 |
-
text = " ".join(await item.locator("span").all_inner_texts())
|
| 149 |
-
match = re.search(PATENT_ID_REGEX, text)
|
| 150 |
-
if not match:
|
| 151 |
-
continue
|
| 152 |
-
|
| 153 |
-
patent_id = match.group()
|
| 154 |
-
|
| 155 |
-
try:
|
| 156 |
-
title = await item.locator("h3, h4").first.inner_text(timeout=1000)
|
| 157 |
-
body = await item.locator("div.abstract, div.result-snippet, .snippet, .result-text").first.inner_text(timeout=1000)
|
| 158 |
-
except:
|
| 159 |
-
continue # If we can't get title or body, skip this item
|
| 160 |
|
| 161 |
-
results.append({
|
| 162 |
-
"id": patent_id,
|
| 163 |
-
"href": f"https://patents.google.com/patent/{patent_id}/en",
|
| 164 |
-
"title": title,
|
| 165 |
-
"body": body
|
| 166 |
-
})
|
| 167 |
|
| 168 |
-
|
|
|
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
|
| 174 |
-
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
if request.resource_type in ["stylesheet", "image"]:
|
| 178 |
-
await route.abort()
|
| 179 |
-
else:
|
| 180 |
-
await route.continue_()
|
| 181 |
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
|
|
|
| 186 |
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
if len(
|
| 190 |
-
|
| 191 |
|
| 192 |
-
|
| 193 |
-
raise BraveSearchBlockedException()
|
| 194 |
|
| 195 |
-
results = []
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
# Filter out results with no URL or brave-specific URLs
|
| 203 |
-
if url is None or url.startswith('/'):
|
| 204 |
-
continue
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
results.append({
|
| 207 |
-
"title": title
|
| 208 |
-
"
|
| 209 |
-
"
|
| 210 |
})
|
| 211 |
|
| 212 |
-
|
| 213 |
-
break
|
| 214 |
-
|
| 215 |
-
return results
|
| 216 |
|
| 217 |
|
| 218 |
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
|
| 219 |
"""Queries bing search for the specified query"""
|
| 220 |
async with playwright_open_page(browser) as page:
|
| 221 |
-
|
| 222 |
-
if request.resource_type in ["stylesheet", "image"]:
|
| 223 |
-
await route.abort()
|
| 224 |
-
else:
|
| 225 |
-
await route.continue_()
|
| 226 |
-
|
| 227 |
-
await page.route("**/*", _block_resources)
|
| 228 |
|
| 229 |
url = f"https://www.bing.com/search?q={quote_plus(q)}"
|
| 230 |
await page.goto(url)
|
| 231 |
|
| 232 |
-
await
|
| 233 |
-
|
| 234 |
-
results = []
|
| 235 |
-
|
| 236 |
-
items = await page.query_selector_all("li.b_algo")
|
| 237 |
-
for item in items[:n_results]:
|
| 238 |
-
title_el = await item.query_selector("h2 > a")
|
| 239 |
-
url = await title_el.get_attribute("href") if title_el else None
|
| 240 |
-
title = await title_el.inner_text() if title_el else ""
|
| 241 |
-
|
| 242 |
-
snippet = ""
|
| 243 |
-
|
| 244 |
-
# Try several fallback selectors
|
| 245 |
-
for selector in [
|
| 246 |
-
"div.b_caption p", # typical snippet
|
| 247 |
-
"div.b_caption", # sometimes snippet is here
|
| 248 |
-
"div.b_snippet", # used in some result types
|
| 249 |
-
"div.b_text", # used in some panels
|
| 250 |
-
"p" # fallback to any paragraph
|
| 251 |
-
]:
|
| 252 |
-
snippet_el = await item.query_selector(selector)
|
| 253 |
-
if snippet_el:
|
| 254 |
-
snippet = await snippet_el.inner_text()
|
| 255 |
-
if snippet.strip():
|
| 256 |
-
break
|
| 257 |
-
|
| 258 |
-
if title and url:
|
| 259 |
-
results.append({
|
| 260 |
-
"title": title.strip(),
|
| 261 |
-
"href": url.strip(),
|
| 262 |
-
"body": snippet.strip()
|
| 263 |
-
})
|
| 264 |
-
|
| 265 |
-
return results
|
| 266 |
|
| 267 |
|
| 268 |
async def query_ddg_search(q: str, n_results: int = 10):
|
|
|
|
| 82 |
await context.close()
|
| 83 |
|
| 84 |
|
| 85 |
+
async def _block_stylesheet_and_image_resources(route, request):
|
| 86 |
+
"""Shared `page.route("**/*", ...)` handler for every scraper below: skip
|
| 87 |
+
fetching stylesheets/images, since only the page's text content matters
|
| 88 |
+
here and this meaningfully speeds up navigation.
|
| 89 |
+
"""
|
| 90 |
+
if request.resource_type in ["stylesheet", "image"]:
|
| 91 |
+
await route.abort()
|
| 92 |
+
else:
|
| 93 |
+
await route.continue_()
|
| 94 |
|
|
|
|
| 95 |
|
| 96 |
+
async def _extract_google_scholar_results(page: Page, n_results: int) -> list[dict]:
|
| 97 |
+
"""Extract results from an already-loaded Google Scholar results page."""
|
| 98 |
+
await page.wait_for_selector("div.gs_ri")
|
| 99 |
+
|
| 100 |
+
items = await page.locator("div.gs_ri").all()
|
| 101 |
+
results = []
|
| 102 |
+
for item in items[:n_results]:
|
| 103 |
+
title = await item.locator("h3").inner_text(timeout=1000)
|
| 104 |
+
body = await item.locator("div.gs_rs").inner_text(timeout=1000)
|
| 105 |
+
href = await item.locator("h3 > a").get_attribute("href")
|
| 106 |
+
|
| 107 |
+
results.append({
|
| 108 |
+
"title": title,
|
| 109 |
+
"body": body,
|
| 110 |
+
"href": href
|
| 111 |
+
})
|
| 112 |
|
| 113 |
+
return results
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
|
| 117 |
+
"""Queries google scholar for the specified query and number of results. Returns relevant papers"""
|
| 118 |
+
async with playwright_open_page(browser) as page:
|
| 119 |
+
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 120 |
|
| 121 |
url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
|
| 122 |
await page.goto(url)
|
| 123 |
|
| 124 |
+
return await _extract_google_scholar_results(page, n_results)
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
# regex to locate a patent id, e.g. "US11930446B2" or "EP4760514A1"
|
| 128 |
+
PATENT_ID_REGEX = r"\b[A-Z]{2}\d{6,}(?:[A-Z]\d?)?\b"
|
|
|
|
|
|
|
|
|
|
| 129 |
|
|
|
|
| 130 |
|
| 131 |
+
async def _extract_google_patents_results(page: Page, n_results: int) -> list[dict]:
|
| 132 |
+
"""Extract results from an already-loaded Google Patents search page."""
|
| 133 |
+
# Wait for at least one search result item to appear
|
| 134 |
+
# This ensures the page has loaded enough to start scraping
|
| 135 |
+
await page.wait_for_function(
|
| 136 |
+
f"""() => document.querySelectorAll('search-result-item').length >= 1""",
|
| 137 |
+
timeout=30_000
|
| 138 |
+
)
|
| 139 |
|
| 140 |
+
items = await page.locator("search-result-item").all()
|
| 141 |
+
results = []
|
| 142 |
+
for item in items:
|
| 143 |
+
text = " ".join(await item.locator("span").all_inner_texts())
|
| 144 |
+
match = re.search(PATENT_ID_REGEX, text)
|
| 145 |
+
if not match:
|
| 146 |
+
continue
|
| 147 |
|
| 148 |
+
patent_id = match.group()
|
|
|
|
| 149 |
|
| 150 |
+
try:
|
| 151 |
+
title = await item.locator("h3, h4").first.inner_text(timeout=1000)
|
| 152 |
+
body = await item.locator("div.abstract, div.result-snippet, .snippet, .result-text").first.inner_text(timeout=1000)
|
| 153 |
+
except Exception:
|
| 154 |
+
continue # If we can't get title or body, skip this item
|
| 155 |
+
|
| 156 |
+
results.append({
|
| 157 |
+
"id": patent_id,
|
| 158 |
+
"href": f"https://patents.google.com/patent/{patent_id}/en",
|
| 159 |
+
"title": title,
|
| 160 |
+
"body": body
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
return results[:n_results]
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
|
| 167 |
+
"""Queries google patents for the specified query and number of results. Returns relevant patents"""
|
| 168 |
+
async with playwright_open_page(browser) as page:
|
| 169 |
+
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 170 |
|
| 171 |
url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
|
| 172 |
await page.goto(url)
|
| 173 |
|
| 174 |
+
return await _extract_google_patents_results(page, n_results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
|
| 178 |
+
"""Extract results from an already-loaded Brave Search results page.
|
| 179 |
|
| 180 |
+
Raises BraveSearchBlockedException if the page looks like Brave's
|
| 181 |
+
anti-bot interstitial rather than a results page.
|
| 182 |
+
"""
|
| 183 |
+
results_cards = await page.locator('.snippet').all()
|
| 184 |
|
| 185 |
+
if len(results_cards) == 0:
|
| 186 |
+
page_content = await page.content()
|
| 187 |
|
| 188 |
+
if "suspicious" in page_content:
|
| 189 |
+
raise BraveSearchBlockedException()
|
| 190 |
|
| 191 |
+
results = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
for result in results_cards:
|
| 194 |
+
title = await result.locator('.title').all_inner_texts()
|
| 195 |
+
description = await result.locator('.snippet-description').all_inner_texts()
|
| 196 |
+
url = await result.locator('a').nth(0).get_attribute('href')
|
| 197 |
|
| 198 |
+
# Filter out results with no URL or brave-specific URLs
|
| 199 |
+
if url is None or url.startswith('/'):
|
| 200 |
+
continue
|
| 201 |
|
| 202 |
+
results.append({
|
| 203 |
+
"title": title[0] if title else "",
|
| 204 |
+
"body": description[0] if description else "",
|
| 205 |
+
"href": url
|
| 206 |
+
})
|
| 207 |
|
| 208 |
+
if len(results) >= n_results:
|
| 209 |
+
break
|
| 210 |
|
| 211 |
+
return results
|
|
|
|
| 212 |
|
|
|
|
| 213 |
|
| 214 |
+
async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
|
| 215 |
+
"""Queries Brave Search for the specified query."""
|
| 216 |
+
async with playwright_open_page(browser) as page:
|
| 217 |
+
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 218 |
+
|
| 219 |
+
url = f"https://search.brave.com/search?q={quote_plus(q)}"
|
| 220 |
+
await page.goto(url)
|
| 221 |
+
|
| 222 |
+
return await _extract_brave_results(page, n_results)
|
| 223 |
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
+
async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
|
| 226 |
+
"""Extract results from an already-loaded Bing results page."""
|
| 227 |
+
await page.wait_for_selector("li.b_algo")
|
| 228 |
+
|
| 229 |
+
results = []
|
| 230 |
+
|
| 231 |
+
items = await page.query_selector_all("li.b_algo")
|
| 232 |
+
for item in items[:n_results]:
|
| 233 |
+
title_el = await item.query_selector("h2 > a")
|
| 234 |
+
url = await title_el.get_attribute("href") if title_el else None
|
| 235 |
+
title = await title_el.inner_text() if title_el else ""
|
| 236 |
+
|
| 237 |
+
snippet = ""
|
| 238 |
+
|
| 239 |
+
# Try several fallback selectors
|
| 240 |
+
for selector in [
|
| 241 |
+
"div.b_caption p", # typical snippet
|
| 242 |
+
"div.b_caption", # sometimes snippet is here
|
| 243 |
+
"div.b_snippet", # used in some result types
|
| 244 |
+
"div.b_text", # used in some panels
|
| 245 |
+
"p" # fallback to any paragraph
|
| 246 |
+
]:
|
| 247 |
+
snippet_el = await item.query_selector(selector)
|
| 248 |
+
if snippet_el:
|
| 249 |
+
snippet = await snippet_el.inner_text()
|
| 250 |
+
if snippet.strip():
|
| 251 |
+
break
|
| 252 |
+
|
| 253 |
+
if title and url:
|
| 254 |
results.append({
|
| 255 |
+
"title": title.strip(),
|
| 256 |
+
"href": url.strip(),
|
| 257 |
+
"body": snippet.strip()
|
| 258 |
})
|
| 259 |
|
| 260 |
+
return results
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
|
| 263 |
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
|
| 264 |
"""Queries bing search for the specified query"""
|
| 265 |
async with playwright_open_page(browser) as page:
|
| 266 |
+
await page.route("**/*", _block_stylesheet_and_image_resources)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
url = f"https://www.bing.com/search?q={quote_plus(q)}"
|
| 269 |
await page.goto(url)
|
| 270 |
|
| 271 |
+
return await _extract_bing_results(page, n_results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
|
| 274 |
async def query_ddg_search(q: str, n_results: int = 10):
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 32 |
+
async def browser():
|
| 33 |
+
"""A real headless Chromium instance, exactly like the one `app.py`'s
|
| 34 |
+
lifespan starts in production.
|
| 35 |
+
|
| 36 |
+
Function-scoped (one launch per test) rather than session-scoped: each
|
| 37 |
+
pytest-asyncio test runs on its own event loop by default, and a
|
| 38 |
+
session-scoped instance would be created on one loop's setup and then
|
| 39 |
+
awaited from a different, later loop in the next test. Playwright's
|
| 40 |
+
connection keeps a background reader task bound to the loop it was
|
| 41 |
+
created on, so calls from another loop don't error - they just hang
|
| 42 |
+
forever waiting for a reply the old loop's task will never deliver.
|
| 43 |
+
Confirmed by reproducing this exact hang with a session-scoped version
|
| 44 |
+
of this fixture, then ruling out the scraping logic itself by running
|
| 45 |
+
the same call in a plain script outside pytest (single event loop),
|
| 46 |
+
where it completed instantly. A fresh browser per test costs roughly a
|
| 47 |
+
second of extra startup but sidesteps the whole cross-loop class of bug.
|
| 48 |
+
"""
|
| 49 |
+
from playwright.async_api import async_playwright
|
| 50 |
+
|
| 51 |
+
pw = await async_playwright().start()
|
| 52 |
+
try:
|
| 53 |
+
b = await pw.chromium.launch(headless=True)
|
| 54 |
+
except Exception:
|
| 55 |
+
executable_path = _find_chromium_executable()
|
| 56 |
+
if not executable_path:
|
| 57 |
+
raise
|
| 58 |
+
b = await pw.chromium.launch(headless=True, executable_path=executable_path)
|
| 59 |
+
yield b
|
| 60 |
+
await b.close()
|
| 61 |
+
await pw.stop()
|
tests/fixtures/arxiv_response.xml
ADDED
|
@@ -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>
|
tests/fixtures/bing_results.html
ADDED
|
@@ -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>
|
tests/fixtures/brave_blocked.html
ADDED
|
@@ -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>
|
tests/fixtures/brave_results.html
ADDED
|
@@ -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>
|
tests/fixtures/ops/biblio_response.json
ADDED
|
@@ -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 |
+
}
|
tests/fixtures/ops/claims_response.json
ADDED
|
@@ -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 |
+
}
|
tests/fixtures/ops/description_response.json
ADDED
|
@@ -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 |
+
}
|
tests/fixtures/ops/search_not_found_response.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fault": {
|
| 3 |
+
"faultstring": "SERVER.EntityNotFound",
|
| 4 |
+
"detail": {"errorcode": "SERVER.EntityNotFound"}
|
| 5 |
+
}
|
| 6 |
+
}
|
tests/fixtures/ops/search_response.json
ADDED
|
@@ -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 |
+
}
|
tests/fixtures/ops/token_response.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"access_token": "test-access-token-123",
|
| 3 |
+
"expires_in": 1199,
|
| 4 |
+
"token_type": "Bearer"
|
| 5 |
+
}
|
tests/fixtures/patents/full_patent.html
ADDED
|
@@ -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>
|
tests/fixtures/patents/missing_title.html
ADDED
|
@@ -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>
|
tests/fixtures/patents/search_results.html
ADDED
|
@@ -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>
|
tests/fixtures/scholar_results.html
ADDED
|
@@ -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>
|
tests/helpers.py
ADDED
|
@@ -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)
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Characterization tests for app.py's search endpoints.
|
| 2 |
+
|
| 3 |
+
These monkeypatch the underlying query_*/ops_search functions rather than
|
| 4 |
+
hitting real search engines - app.py's own job here is just wiring
|
| 5 |
+
(gather queries concurrently, log failures, flatten results, decide when to
|
| 6 |
+
report an error), which is exactly what's being tested. The app's lifespan
|
| 7 |
+
(which starts a real Playwright browser) is never triggered: ASGITransport
|
| 8 |
+
doesn't run it, and these tests don't need it since pw_browser is never
|
| 9 |
+
actually used once the query functions are patched out.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
import pytest
|
| 14 |
+
from httpx import ASGITransport
|
| 15 |
+
|
| 16 |
+
import app as app_module
|
| 17 |
+
from serp import SerpQuery
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
async def client():
|
| 22 |
+
transport = ASGITransport(app=app_module.app)
|
| 23 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
| 24 |
+
yield c
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ------------------------- _shape_serp_results / _run_serp_queries -------------------------
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_shape_serp_results_flattens_successful_lists():
|
| 31 |
+
result = app_module._shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
|
| 32 |
+
assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
|
| 33 |
+
assert result.error is None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_shape_serp_results_surfaces_last_error_when_all_failed():
|
| 37 |
+
result = app_module._shape_serp_results([ValueError("first"), RuntimeError("second")])
|
| 38 |
+
assert result.results == []
|
| 39 |
+
assert "second" in result.error
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_shape_serp_results_ignores_failures_when_some_succeed():
|
| 43 |
+
result = app_module._shape_serp_results([[{"a": 1}], RuntimeError("boom")])
|
| 44 |
+
assert result.results == [{"a": 1}]
|
| 45 |
+
assert result.error is None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def test_run_serp_queries_calls_fn_per_query_and_shapes_result():
|
| 49 |
+
params = SerpQuery(queries=["x", "y"], n_results=5)
|
| 50 |
+
calls = []
|
| 51 |
+
|
| 52 |
+
async def fn(q, n):
|
| 53 |
+
calls.append((q, n))
|
| 54 |
+
return [{"q": q}]
|
| 55 |
+
|
| 56 |
+
result = await app_module._run_serp_queries(fn, params, "test context")
|
| 57 |
+
|
| 58 |
+
assert sorted(calls) == [("x", 5), ("y", 5)]
|
| 59 |
+
assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ------------------------------------- endpoints -------------------------------------
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def test_search_arxiv_flattens_results_from_all_queries(client, monkeypatch):
|
| 66 |
+
async def fake_query_arxiv(client_arg, q, n):
|
| 67 |
+
return [{"title": f"paper about {q}", "href": "x", "body": "y", "id": "1"}]
|
| 68 |
+
|
| 69 |
+
monkeypatch.setattr(app_module, "query_arxiv", fake_query_arxiv)
|
| 70 |
+
|
| 71 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": ["a", "b"], "n_results": 5})
|
| 72 |
+
|
| 73 |
+
assert resp.status_code == 200
|
| 74 |
+
data = resp.json()
|
| 75 |
+
assert data["error"] is None
|
| 76 |
+
assert {r["title"] for r in data["results"]} == {"paper about a", "paper about b"}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_search_arxiv_all_queries_failing_returns_error(client, monkeypatch):
|
| 80 |
+
async def failing_query_arxiv(client_arg, q, n):
|
| 81 |
+
raise RuntimeError(f"boom for {q}")
|
| 82 |
+
|
| 83 |
+
monkeypatch.setattr(app_module, "query_arxiv", failing_query_arxiv)
|
| 84 |
+
|
| 85 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": ["a"], "n_results": 5})
|
| 86 |
+
|
| 87 |
+
assert resp.status_code == 200
|
| 88 |
+
data = resp.json()
|
| 89 |
+
assert data["results"] == []
|
| 90 |
+
assert "boom for a" in data["error"]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def test_search_patents_does_not_fall_back_when_google_patents_succeeds(client, monkeypatch):
|
| 94 |
+
async def fake_google_patents(browser, q, n):
|
| 95 |
+
return [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
|
| 96 |
+
|
| 97 |
+
ops_search_called = False
|
| 98 |
+
|
| 99 |
+
async def fake_ops_search(client_arg, q, n):
|
| 100 |
+
nonlocal ops_search_called
|
| 101 |
+
ops_search_called = True
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 105 |
+
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 106 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
|
| 107 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
|
| 108 |
+
|
| 109 |
+
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 110 |
+
|
| 111 |
+
data = resp.json()
|
| 112 |
+
assert data["results"] == [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
|
| 113 |
+
assert ops_search_called is False
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def test_search_patents_falls_back_to_ops_when_google_patents_empty(client, monkeypatch):
|
| 117 |
+
async def fake_google_patents(browser, q, n):
|
| 118 |
+
return []
|
| 119 |
+
|
| 120 |
+
async def fake_ops_search(client_arg, q, n):
|
| 121 |
+
return [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
|
| 122 |
+
|
| 123 |
+
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 124 |
+
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 125 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
|
| 126 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
|
| 127 |
+
|
| 128 |
+
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 129 |
+
|
| 130 |
+
data = resp.json()
|
| 131 |
+
assert data["results"] == [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def test_search_patents_no_fallback_when_ops_not_configured(client, monkeypatch):
|
| 135 |
+
async def fake_google_patents(browser, q, n):
|
| 136 |
+
return []
|
| 137 |
+
|
| 138 |
+
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 139 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
|
| 140 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
|
| 141 |
+
|
| 142 |
+
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 143 |
+
|
| 144 |
+
data = resp.json()
|
| 145 |
+
assert data["results"] == []
|
| 146 |
+
assert data["error"] is None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def test_ops_keyword_search_flattens_results(client, monkeypatch):
|
| 150 |
+
async def fake_ops_search(client_arg, q, n):
|
| 151 |
+
return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
|
| 152 |
+
|
| 153 |
+
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 154 |
+
|
| 155 |
+
resp = await client.post("/ops/search", json={"queries": ["a", "b"], "n_results": 5})
|
| 156 |
+
|
| 157 |
+
data = resp.json()
|
| 158 |
+
assert data["error"] is None
|
| 159 |
+
assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
|
tests/test_ops_client.py
ADDED
|
@@ -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
|
tests/test_ops_parsing.py
ADDED
|
@@ -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
|
tests/test_scrap.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import pytest
|
| 3 |
+
import respx
|
| 4 |
+
|
| 5 |
+
from helpers import load_fixture
|
| 6 |
+
from scrap import parse_patent_html, 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 |
+
def test_parse_patent_html_extracts_all_fields_with_no_network_involved():
|
| 13 |
+
"""`scrap_patent_async` is now a thin fetch that delegates to this - a
|
| 14 |
+
plain, synchronous function of the HTML string, so the parsing logic
|
| 15 |
+
(including the regex-based section splitting, which is the part most
|
| 16 |
+
likely to break when Google Patents changes its markup) can be tested
|
| 17 |
+
directly against a saved page with no client/respx/event loop needed.
|
| 18 |
+
"""
|
| 19 |
+
result = parse_patent_html(FULL_PATENT_HTML, "https://patents.google.com/patent/US11930446B2/en")
|
| 20 |
+
|
| 21 |
+
assert result.title == "Widget with improved gadget mechanism"
|
| 22 |
+
codes = {c.code: c.description for c in result.classifications}
|
| 23 |
+
assert codes == {
|
| 24 |
+
"G06F17/30": "Database structures therefor",
|
| 25 |
+
"G06F17/50": "Other database related",
|
| 26 |
+
"H04L9/00": "Cryptographic mechanisms",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_parse_patent_html_raises_when_page_has_no_title():
|
| 31 |
+
with pytest.raises(ValueError):
|
| 32 |
+
parse_patent_html(MISSING_TITLE_HTML, "https://patents.google.com/patent/BOGUS/en")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def test_scrap_patent_async_extracts_all_fields():
|
| 36 |
+
url = "https://patents.google.com/patent/US11930446B2/en"
|
| 37 |
+
with respx.mock:
|
| 38 |
+
respx.get(url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
|
| 39 |
+
async with httpx.AsyncClient() as client:
|
| 40 |
+
result = await scrap_patent_async(client, url)
|
| 41 |
+
|
| 42 |
+
assert result.title == "Widget with improved gadget mechanism"
|
| 43 |
+
assert result.abstract == "A widget comprising a gadget and a mechanism for improving widget performance."
|
| 44 |
+
assert result.field_of_invention == "This invention relates to widgets and gadgets for testing purposes."
|
| 45 |
+
assert result.background == "Prior art widgets suffered from several problems described herein."
|
| 46 |
+
assert "1. A widget comprising a gadget." in result.claims
|
| 47 |
+
assert "2. The widget of claim 1, further comprising a mechanism." in result.claims
|
| 48 |
+
|
| 49 |
+
codes = {c.code: c.description for c in result.classifications}
|
| 50 |
+
assert codes == {
|
| 51 |
+
"G06F17/30": "Database structures therefor",
|
| 52 |
+
"G06F17/50": "Other database related",
|
| 53 |
+
"H04L9/00": "Cryptographic mechanisms",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_scrap_patent_async_raises_when_page_has_no_title():
|
| 58 |
+
"""Pins the documented behavior at scrap.py: an unexpected page layout
|
| 59 |
+
(interstitial, non-patent page, markup change) raises ValueError rather
|
| 60 |
+
than crashing on `None.get(...)`, so callers can treat it as a failed
|
| 61 |
+
scrape.
|
| 62 |
+
"""
|
| 63 |
+
url = "https://patents.google.com/patent/BOGUS/en"
|
| 64 |
+
with respx.mock:
|
| 65 |
+
respx.get(url).mock(return_value=httpx.Response(200, text=MISSING_TITLE_HTML))
|
| 66 |
+
async with httpx.AsyncClient() as client:
|
| 67 |
+
with pytest.raises(ValueError):
|
| 68 |
+
await scrap_patent_async(client, url)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def test_scrap_patent_async_raises_on_http_error():
|
| 72 |
+
url = "https://patents.google.com/patent/US00000000/en"
|
| 73 |
+
with respx.mock:
|
| 74 |
+
respx.get(url).mock(return_value=httpx.Response(404))
|
| 75 |
+
async with httpx.AsyncClient() as client:
|
| 76 |
+
with pytest.raises(httpx.HTTPStatusError):
|
| 77 |
+
await scrap_patent_async(client, url)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def test_scrap_patent_bulk_async_separates_successes_from_failures():
|
| 81 |
+
ok_url = "https://patents.google.com/patent/US_OK/en"
|
| 82 |
+
fail_url = "https://patents.google.com/patent/US_FAIL/en"
|
| 83 |
+
with respx.mock:
|
| 84 |
+
respx.get(ok_url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
|
| 85 |
+
respx.get(fail_url).mock(return_value=httpx.Response(404))
|
| 86 |
+
async with httpx.AsyncClient() as client:
|
| 87 |
+
result = await scrap_patent_bulk_async(client, ["US_OK", "US_FAIL"])
|
| 88 |
+
|
| 89 |
+
assert len(result.patents) == 1
|
| 90 |
+
assert result.patents[0].title == "Widget with improved gadget mechanism"
|
| 91 |
+
assert result.failed_ids == ["US_FAIL"]
|
tests/test_serp_arxiv.py
ADDED
|
@@ -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 |
+
]
|
tests/test_serp_ddg.py
ADDED
|
@@ -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)
|
tests/test_serp_models.py
ADDED
|
@@ -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
|
tests/test_serp_playwright.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
_extract_bing_results,
|
| 17 |
+
playwright_open_page,
|
| 18 |
+
query_bing_search,
|
| 19 |
+
query_brave_search,
|
| 20 |
+
query_google_patents,
|
| 21 |
+
query_google_scholar,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
|
| 26 |
+
"""Demonstrates the payoff of splitting fetch (navigate) from parse
|
| 27 |
+
(extract): `_extract_bing_results` takes a Page that's already loaded,
|
| 28 |
+
so this needs nothing beyond `set_content` - no goto-patching wrapper,
|
| 29 |
+
no browser-as-a-parameter indirection.
|
| 30 |
+
"""
|
| 31 |
+
context = await browser.new_context()
|
| 32 |
+
page = await context.new_page()
|
| 33 |
+
await page.set_content(load_fixture("bing_results.html"))
|
| 34 |
+
|
| 35 |
+
results = await _extract_bing_results(page, 10)
|
| 36 |
+
|
| 37 |
+
assert results == [
|
| 38 |
+
{"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
|
| 39 |
+
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
await context.close()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def test_bing_search_parses_results(browser):
|
| 46 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
|
| 47 |
+
|
| 48 |
+
results = await query_bing_search(fixture_browser, "test query", 10)
|
| 49 |
+
|
| 50 |
+
assert results == [
|
| 51 |
+
{"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
|
| 52 |
+
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def test_brave_search_parses_results(browser):
|
| 57 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("brave_results.html"))
|
| 58 |
+
|
| 59 |
+
results = await query_brave_search(fixture_browser, "test query", 10)
|
| 60 |
+
|
| 61 |
+
assert results == [
|
| 62 |
+
{"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
|
| 63 |
+
{"title": "Brave Result B", "body": "Brave snippet B", "href": "https://example.org/brave-b"},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def test_brave_search_detects_the_suspicious_block_page(browser):
|
| 68 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("brave_blocked.html"))
|
| 69 |
+
|
| 70 |
+
with pytest.raises(BraveSearchBlockedException):
|
| 71 |
+
await query_brave_search(fixture_browser, "test query", 10)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def test_google_scholar_parses_results(browser):
|
| 75 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("scholar_results.html"))
|
| 76 |
+
|
| 77 |
+
results = await query_google_scholar(fixture_browser, "test query", 10)
|
| 78 |
+
|
| 79 |
+
assert results == [
|
| 80 |
+
{"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
|
| 81 |
+
{"title": "Scholar Paper B", "body": "Scholar snippet B", "href": "https://example.org/scholar-b"},
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def test_google_patents_parses_results(browser):
|
| 86 |
+
fixture_browser = make_fixture_browser(browser, load_fixture("patents", "search_results.html"))
|
| 87 |
+
|
| 88 |
+
results = await query_google_patents(fixture_browser, "test query", 10)
|
| 89 |
+
|
| 90 |
+
assert results == [
|
| 91 |
+
{
|
| 92 |
+
"id": "US11930446B2",
|
| 93 |
+
"href": "https://patents.google.com/patent/US11930446B2/en",
|
| 94 |
+
"title": "Widget apparatus",
|
| 95 |
+
"body": "An apparatus comprising a widget.",
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "EP4760514A1",
|
| 99 |
+
"href": "https://patents.google.com/patent/EP4760514A1/en",
|
| 100 |
+
"title": "Gadget system",
|
| 101 |
+
"body": "A system comprising a gadget.",
|
| 102 |
+
},
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def test_playwright_open_page_raises_when_browser_unavailable():
|
| 107 |
+
with pytest.raises(BrowserUnavailableError):
|
| 108 |
+
async with playwright_open_page(None):
|
| 109 |
+
pass
|
tests/test_utils.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_its_own_logger_not_the_asyncio_one(caplog):
|
| 32 |
+
"""Was `from asyncio.log import logger` (flagged in review), which
|
| 33 |
+
misattributed these warnings to the 'asyncio' logging namespace instead
|
| 34 |
+
of the app's own. Fixed to `logging.getLogger(__name__)`.
|
| 35 |
+
"""
|
| 36 |
+
params = SerpQuery(queries=["q1"])
|
| 37 |
+
with caplog.at_level(logging.WARNING):
|
| 38 |
+
log_gathered_exceptions([ValueError("boom")], "ctx", params)
|
| 39 |
+
|
| 40 |
+
assert caplog.records[0].name == "utils"
|
utils.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
from serp import SerpQuery
|
| 3 |
|
|
|
|
|
|
|
| 4 |
|
| 5 |
def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
|
| 6 |
"""Logs gathered exceptions with context and parameters."""
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
from serp import SerpQuery
|
| 4 |
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
|
| 8 |
def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
|
| 9 |
"""Logs gathered exceptions with context and parameters."""
|