Spaces:
Sleeping
Sleeping
Upload web_crawler.py with huggingface_hub
Browse files- web_crawler.py +182 -61
web_crawler.py
CHANGED
|
@@ -1,10 +1,19 @@
|
|
| 1 |
"""
|
| 2 |
Tavily-powered web crawler for retrieving polymer datasheets.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
import logging
|
|
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from tavily import TavilyClient
|
|
@@ -13,96 +22,204 @@ import config
|
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
""
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
""
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
base_terms.append(manufacturer)
|
| 29 |
-
if polymer_family:
|
| 30 |
-
base_terms.append(polymer_family)
|
| 31 |
-
if grade:
|
| 32 |
-
base_terms.append(grade)
|
| 33 |
|
| 34 |
-
base = " ".join(base_terms)
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
queries.append(
|
| 45 |
-
f"
|
| 46 |
)
|
| 47 |
|
| 48 |
-
#
|
| 49 |
-
queries.append(
|
| 50 |
-
|
|
|
|
| 51 |
return queries
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def _guess_domain(manufacturer: str) -> str:
|
| 55 |
-
"""
|
| 56 |
-
name = manufacturer.lower().replace(" ", "")
|
| 57 |
for domain in config.TRUSTED_DOMAINS:
|
| 58 |
-
if name in domain:
|
| 59 |
return domain
|
| 60 |
return f"{name}.com"
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def search_datasheets(
|
| 64 |
manufacturer: str,
|
| 65 |
polymer_family: str,
|
| 66 |
grade: str = "",
|
| 67 |
) -> tuple[list[dict[str, Any]], str]:
|
| 68 |
"""
|
| 69 |
-
Execute Tavily
|
|
|
|
| 70 |
"""
|
| 71 |
client = TavilyClient(api_key=config.TAVILY_API_KEY)
|
| 72 |
|
| 73 |
-
queries = build_search_queries(manufacturer, polymer_family, grade)
|
| 74 |
all_results: list[dict[str, Any]] = []
|
| 75 |
seen_urls: set[str] = set()
|
| 76 |
raw_texts: list[str] = []
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
aggregated = "\n".join(raw_texts)
|
| 108 |
|
|
@@ -119,6 +236,10 @@ def search_datasheets(
|
|
| 119 |
return all_results, aggregated
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def extract_from_url(url: str) -> tuple[list[dict[str, Any]], str]:
|
| 123 |
"""
|
| 124 |
Use Tavily extract to get content from a specific URL.
|
|
|
|
| 1 |
"""
|
| 2 |
Tavily-powered web crawler for retrieving polymer datasheets.
|
| 3 |
+
|
| 4 |
+
Strategy:
|
| 5 |
+
Phase 1 β Open web search (no domain filter) for broad discovery.
|
| 6 |
+
Phase 2 β Targeted aggregator search (MatWeb, Omnexus, UL Prospector).
|
| 7 |
+
Phase 3 β Manufacturer-specific search on their own site.
|
| 8 |
+
|
| 9 |
+
Results are de-duplicated, PDF-only URLs are deprioritised (Tavily can't
|
| 10 |
+
read them), and content is scored by relevance before being sent to the LLM.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import logging
|
| 16 |
+
import re
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
from tavily import TavilyClient
|
|
|
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
+
# ββ Keywords that signal real datasheet content ββββββββββββββββββββββββββββββ
|
| 26 |
+
_QUALITY_KEYWORDS = [
|
| 27 |
+
"tensile", "flexural", "density", "melt flow", "elongation",
|
| 28 |
+
"modulus", "impact", "hardness", "HDT", "heat deflection",
|
| 29 |
+
"glass transition", "melting point", "dielectric", "flammability",
|
| 30 |
+
"ISO", "ASTM", "g/cm", "MPa", "kJ/m", "J/m", "Β°C", "shore",
|
| 31 |
+
]
|
| 32 |
|
| 33 |
+
# Domains that are database aggregators (best sources for structured data)
|
| 34 |
+
_AGGREGATOR_DOMAINS = [
|
| 35 |
+
"matweb.com",
|
| 36 |
+
"omnexus.specialchem.com",
|
| 37 |
+
"prospector.ides.com",
|
| 38 |
+
"campusplastics.com",
|
| 39 |
+
"plastics.ulprospector.com",
|
| 40 |
+
"polymerdatabase.com",
|
| 41 |
+
"matmatch.com",
|
| 42 |
+
"materialstoday.com",
|
| 43 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
| 45 |
|
| 46 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
# Query builders
|
| 48 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
def _build_open_queries(
|
| 51 |
+
manufacturer: str, polymer_family: str, grade: str,
|
| 52 |
+
) -> list[str]:
|
| 53 |
+
"""Phase 1: broad web queries with NO domain restriction."""
|
| 54 |
+
parts = [p for p in (manufacturer, polymer_family, grade) if p]
|
| 55 |
+
base = " ".join(parts)
|
| 56 |
+
queries = []
|
| 57 |
|
| 58 |
+
if grade:
|
| 59 |
+
# If a specific grade is given, lead with it
|
| 60 |
+
queries.append(f"{grade} technical data sheet material properties")
|
| 61 |
+
queries.append(f"{grade} {polymer_family} datasheet density tensile")
|
| 62 |
+
else:
|
| 63 |
+
queries.append(f"{base} technical data sheet material properties")
|
| 64 |
queries.append(
|
| 65 |
+
f"{base} datasheet density tensile modulus thermal"
|
| 66 |
)
|
| 67 |
|
| 68 |
+
# A query phrased as a question often surfaces different results
|
| 69 |
+
queries.append(
|
| 70 |
+
f"What are the mechanical and thermal properties of {base}?"
|
| 71 |
+
)
|
| 72 |
return queries
|
| 73 |
|
| 74 |
|
| 75 |
+
def _build_aggregator_queries(
|
| 76 |
+
manufacturer: str, polymer_family: str, grade: str,
|
| 77 |
+
) -> list[str]:
|
| 78 |
+
"""Phase 2: search restricted to well-known aggregator databases."""
|
| 79 |
+
parts = [p for p in (manufacturer, polymer_family, grade) if p]
|
| 80 |
+
base = " ".join(parts)
|
| 81 |
+
return [
|
| 82 |
+
f"{base} properties datasheet",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _build_manufacturer_queries(
|
| 87 |
+
manufacturer: str, polymer_family: str, grade: str,
|
| 88 |
+
) -> list[str]:
|
| 89 |
+
"""Phase 3: search the manufacturer's own website."""
|
| 90 |
+
if not manufacturer:
|
| 91 |
+
return []
|
| 92 |
+
domain = _guess_domain(manufacturer)
|
| 93 |
+
parts = [p for p in (polymer_family, grade) if p]
|
| 94 |
+
material = " ".join(parts) if parts else "polymer"
|
| 95 |
+
return [
|
| 96 |
+
f"site:{domain} {material} datasheet properties",
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
def _guess_domain(manufacturer: str) -> str:
|
| 101 |
+
"""Best-effort manufacturer β domain mapping."""
|
| 102 |
+
name = manufacturer.lower().replace(" ", "").replace("-", "")
|
| 103 |
for domain in config.TRUSTED_DOMAINS:
|
| 104 |
+
if name in domain.replace(".", ""):
|
| 105 |
return domain
|
| 106 |
return f"{name}.com"
|
| 107 |
|
| 108 |
|
| 109 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
# Content quality helpers
|
| 111 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
def _is_pdf_url(url: str) -> bool:
|
| 114 |
+
"""Return True if the URL likely points directly to a PDF file."""
|
| 115 |
+
return bool(re.search(r"\.pdf(\?|#|$)", url, re.IGNORECASE))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _content_quality_score(text: str) -> int:
|
| 119 |
+
"""
|
| 120 |
+
Score how many datasheet-relevant keywords appear in the text.
|
| 121 |
+
Higher = more likely to contain useful property data.
|
| 122 |
+
"""
|
| 123 |
+
lower = text.lower()
|
| 124 |
+
return sum(1 for kw in _QUALITY_KEYWORDS if kw.lower() in lower)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _pick_best_source_url(results: list[dict[str, Any]]) -> str:
|
| 128 |
+
"""Return the URL of the highest-quality non-PDF result."""
|
| 129 |
+
best_url, best_score = "", -1
|
| 130 |
+
for r in results:
|
| 131 |
+
url = r.get("url", "")
|
| 132 |
+
text = r.get("raw_content") or r.get("content", "")
|
| 133 |
+
if _is_pdf_url(url):
|
| 134 |
+
continue # Tavily rarely extracts useful text from PDFs
|
| 135 |
+
score = _content_quality_score(text)
|
| 136 |
+
if score > best_score:
|
| 137 |
+
best_score = score
|
| 138 |
+
best_url = url
|
| 139 |
+
return best_url or (results[0].get("url", "") if results else "")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
# Main search function
|
| 144 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
+
|
| 146 |
def search_datasheets(
|
| 147 |
manufacturer: str,
|
| 148 |
polymer_family: str,
|
| 149 |
grade: str = "",
|
| 150 |
) -> tuple[list[dict[str, Any]], str]:
|
| 151 |
"""
|
| 152 |
+
Execute a multi-phase Tavily search and return
|
| 153 |
+
(results_list, aggregated_raw_content).
|
| 154 |
"""
|
| 155 |
client = TavilyClient(api_key=config.TAVILY_API_KEY)
|
| 156 |
|
|
|
|
| 157 |
all_results: list[dict[str, Any]] = []
|
| 158 |
seen_urls: set[str] = set()
|
| 159 |
raw_texts: list[str] = []
|
| 160 |
|
| 161 |
+
def _run_queries(
|
| 162 |
+
queries: list[str],
|
| 163 |
+
include_domains: list[str] | None = None,
|
| 164 |
+
max_results: int = 5,
|
| 165 |
+
) -> None:
|
| 166 |
+
"""Run a batch of queries and collect unique results."""
|
| 167 |
+
for query in queries:
|
| 168 |
+
try:
|
| 169 |
+
logger.info("Searching: %s (domains=%s)", query, include_domains or "any")
|
| 170 |
+
kwargs: dict[str, Any] = dict(
|
| 171 |
+
query=query,
|
| 172 |
+
search_depth=config.TAVILY_SEARCH_DEPTH,
|
| 173 |
+
max_results=max_results,
|
| 174 |
+
include_raw_content=config.TAVILY_INCLUDE_RAW_CONTENT,
|
| 175 |
+
)
|
| 176 |
+
if include_domains:
|
| 177 |
+
kwargs["include_domains"] = include_domains
|
| 178 |
+
|
| 179 |
+
response = client.search(**kwargs)
|
| 180 |
+
|
| 181 |
+
for result in response.get("results", []):
|
| 182 |
+
url = result.get("url", "")
|
| 183 |
+
if url in seen_urls:
|
| 184 |
+
continue
|
| 185 |
+
seen_urls.add(url)
|
| 186 |
+
|
| 187 |
+
# Skip direct PDF links β Tavily returns no useful text
|
| 188 |
+
if _is_pdf_url(url):
|
| 189 |
+
content = result.get("raw_content") or result.get("content", "")
|
| 190 |
+
if len(content.strip()) < 200:
|
| 191 |
+
logger.info("Skipping PDF URL with no text: %s", url)
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
all_results.append(result)
|
| 195 |
+
|
| 196 |
+
raw = result.get("raw_content") or result.get("content", "")
|
| 197 |
+
if raw and raw.strip():
|
| 198 |
+
raw_texts.append(
|
| 199 |
+
f"--- Source: {url} ---\n{raw[:8000]}\n"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
except Exception as exc:
|
| 203 |
+
logger.warning("Search failed for query '%s': %s", query, exc)
|
| 204 |
+
|
| 205 |
+
# Phase 1 β Open web (no domain filter) for broad discovery
|
| 206 |
+
open_queries = _build_open_queries(manufacturer, polymer_family, grade)
|
| 207 |
+
_run_queries(open_queries, include_domains=None, max_results=5)
|
| 208 |
+
|
| 209 |
+
# Phase 2 β Aggregator databases (MatWeb, Omnexus, etc.)
|
| 210 |
+
agg_queries = _build_aggregator_queries(manufacturer, polymer_family, grade)
|
| 211 |
+
_run_queries(agg_queries, include_domains=_AGGREGATOR_DOMAINS, max_results=5)
|
| 212 |
+
|
| 213 |
+
# Phase 3 β Manufacturer's own website
|
| 214 |
+
mfr_queries = _build_manufacturer_queries(manufacturer, polymer_family, grade)
|
| 215 |
+
if mfr_queries:
|
| 216 |
+
_run_queries(mfr_queries, include_domains=None, max_results=3)
|
| 217 |
+
|
| 218 |
+
# Sort raw_texts so highest-quality content comes first for the LLM
|
| 219 |
+
raw_texts.sort(
|
| 220 |
+
key=lambda t: _content_quality_score(t),
|
| 221 |
+
reverse=True,
|
| 222 |
+
)
|
| 223 |
|
| 224 |
aggregated = "\n".join(raw_texts)
|
| 225 |
|
|
|
|
| 236 |
return all_results, aggregated
|
| 237 |
|
| 238 |
|
| 239 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
+
# Single-URL extraction
|
| 241 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
+
|
| 243 |
def extract_from_url(url: str) -> tuple[list[dict[str, Any]], str]:
|
| 244 |
"""
|
| 245 |
Use Tavily extract to get content from a specific URL.
|