CoolFace
Apppublic

jessejohnson/plg4-dev-server

sourceHugging Facemitupdated 1y agoView on Hugging Face
0likes
base_scrapper.py349 linesDownload Raw Back to data_minning
1from dataclasses import asdict2from datetime import datetime3import io4import json5import os6import time7from typing import Any, Callable, Dict, Iterable, List, Optional, Union8from pathlib import Path9from click import Tuple10from pymongo import MongoClient, UpdateOne, errors11 12from .dto.stream_opts import StreamOptions13 14from .dto.recipe_doc import RecipeDoc15 16from .soup_client import SoupClient17from backend.utils.sanitization import clean18from bs4 import BeautifulSoup19from backend.config.database import db_settings20 21class JsonArraySink:22    """23    Append-safe JSON array writer.24    - Creates file with `[` ... `]`25    - If file exists, removes trailing `]`, appends items, and re-closes.26    """27    def __init__(self, path: str):28        self.path = path29        self._opened = False30        self._first = True31        self.f = None32 33    def _prepare(self):34        if self._opened:35            return36 37        # Ensure file exists with an empty array38        if not os.path.exists(self.path):39            with open(self.path, "w", encoding="utf-8") as f:40                f.write("[\n]")41 42        self.f = open(self.path, "r+", encoding="utf-8")43 44        # Find the position of the final ']' from the end45        self.f.seek(0, io.SEEK_END)46        end = self.f.tell()47        step = min(4096, end)48        pos = end49        last_bracket = -150        while pos > 0:51            pos = max(0, pos - step)52            self.f.seek(pos)53            chunk = self.f.read(step)54            j = chunk.rfind("]")55            if j != -1:56                last_bracket = pos + j57                break58 59        if last_bracket == -1:60            # Corrupt file: reset to empty array61            self.f.seek(0); self.f.truncate(0); self.f.write("[\n]"); self.f.flush()62            last_bracket = 2  # index of ']' in "[\n]"63 64        # Decide "is first item?" by inspecting the content BEFORE the ']'65        self.f.seek(0)66        prefix = self.f.read(last_bracket).strip()   # content up to (but not including) ']'67        # Empty array has only '[' (possibly with whitespace/newline)68        self._first = (prefix == "[")69 70        # Now remove the closing ']' so we can append71        self.f.seek(last_bracket)72        self.f.truncate()73 74        self._opened = True75 76    def write_many(self, docs: List[Dict[str, Any]]):77        if not docs:78            return79        self._prepare()80 81        for d in docs:82            if not self._first:83                self.f.write(",\n")84            else:85                # First item: no leading comma86                self._first = False87            self.f.write(json.dumps(d, ensure_ascii=False, indent=2, default=str))88 89        # Restore the closing bracket90        self.f.write("\n]")91        self.f.flush()92 93    def close(self):94        if self.f:95            self.f.close()96        self._opened = False97 98class MongoSink:99    def __init__(self, ):100        db_config = db_settings.get_vector_store_config()101        self.client = MongoClient(db_config["uri"], retryWrites=True, serverSelectionTimeoutMS=10000)102        self.col = self.client[db_config["database"]][db_config["collection_name"]]103        self._ensure_indexes()104 105    def _ensure_indexes(self):106        self.col.create_index("url", unique=True)107        self.col.create_index("title")108        self.col.create_index("category")109        self.col.create_index("scraped_at")110 111    def write_many(self, docs: List[Dict[str, Any]]):112        if not docs: return113        ops = []114        now = datetime.utcnow()115        for d in docs:116            d = d.copy()117            d.setdefault("scraped_at", now)118            ops.append(UpdateOne({"url": d["url"]}, {"$set": d, "$setOnInsert": {"created_at": now}}, upsert=True))119        try:120            self.col.bulk_write(ops, ordered=False)121        except errors.BulkWriteError as e:122            # duplicates or minor issues won't halt unordered bulk123            pass124 125    def close(self):126        self.client.close()127 128    # we need to create a search function to fetch recipes by title or ingredients from the embeddings given the embedding fields, db and collection 129 130class DualSink:131    def __init__(self, json_sink: Optional[JsonArraySink], mongo_sink: Optional[MongoSink]):132        self.json = json_sink133        self.mongo = mongo_sink134    def write_many(self, docs: List[Dict[str, Any]]):135        if self.json: self.json.write_many(docs)136        if self.mongo: self.mongo.upsert_batch(docs)137    def close(self):138        if self.json: self.json.close()139        if self.mongo: self.mongo.close()140 141 142class BaseRecipeScraper(SoupClient):143    HEADING_TAGS = ("h1","h2","h3","h4","h5","h6")144    145    def __init__(146        self,147        *args,148        embedder= None,149        embedding_fields= None,150        **kwargs151    ):152        """153        embedder: HFEmbedder(), optional154        embedding_fields: list of (source_field, target_field) like:155            [("title", "title_emb"), ("instructions_text", "instr_emb")]156        """157        super().__init__(*args, **kwargs)158        self.embedder = embedder159        self.embedding_fields = embedding_fields or []160        self.logger = self.log161 162    def extract_jsonld(self, soup: BeautifulSoup) -> Optional[Dict[str, Any]]:163        def to_list(x): return x if isinstance(x, list) else [x]164        for tag in soup.find_all("script", type="application/ld+json"):165            try:166                data = json.loads(tag.string or "{}")167            except Exception:168                continue169            nodes = (data.get("@graph", [data]) if isinstance(data, dict)170                     else (data if isinstance(data, list) else []))171            for n in nodes:172                if not isinstance(n, dict): continue173                t = n.get("@type")174                if t == "Recipe" or (isinstance(t, list) and "Recipe" in t):175                    doc = RecipeDoc()176                    doc.title = clean(n.get("name"))177                    # ingredients178                    ings = []179                    for ing in to_list(n.get("recipeIngredient") or []):180                        if isinstance(ing, dict):181                            ings.append(clean(ing.get("name") or ing.get("text")))182                        else:183                            ings.append(clean(str(ing)))184                    doc.ingredients = [x for x in ings if x]185                    # instructions186                    steps = []187                    for st in to_list(n.get("recipeInstructions") or []):188                        if isinstance(st, dict):189                            steps.append(clean(st.get("text") or st.get("name")))190                        else:191                            steps.append(clean(str(st)))192                    doc.instructions = [x for x in steps if x]193                   194                    doc.servings = n.get("recipeYield")195                    doc.image_url = clean((n.get("image") or {}).get("url") if isinstance(n.get("image"), dict) else (n.get("image")[0] if isinstance(n.get("image"), list) else n.get("image")))196                    doc.course = clean(n.get("recipeCategory")) if isinstance(n.get("recipeCategory"), str) else None197                    doc.cuisine = clean(n.get("recipeCuisine")) if isinstance(n.get("recipeCuisine"), str) else None198                    return asdict(doc)199        return None200    201    @staticmethod202    def _dedupe_preserve_order(items: List[str]) -> List[str]:203        seen = set()204        out = []205        for x in items:206            x = clean(x)207            if not x or x in seen: 208                continue209            seen.add(x); out.append(x)210        return out211 212    @staticmethod213    def _to_ingredients_text(items: List[str]) -> str:214        """215        Turn ingredient bullets into a single text block.216        Using one-per-line is great for embeddings and human readability.217        """218        items = [clean(x) for x in items if x]219        items = BaseRecipeScraper._dedupe_preserve_order(items)220        return "\n".join(f"- {x}" for x in items)221 222    @staticmethod223    def _to_instructions_text(steps: List[str]) -> str:224        """225        Turn ordered steps into a single text block.226        Numbered paragraphs help embeddings keep sequence context.227        """228        steps = [clean(x) for x in steps if x]229        steps = BaseRecipeScraper._dedupe_preserve_order(steps)230        return "\n\n".join(f"{i}. {s}" for i, s in enumerate(steps, 1))231    # site-specific scrapers override these two:232    def discover_urls(self) -> Iterable[str]:233        raise NotImplementedError234    def extract_recipe(self, soup: BeautifulSoup, url: str, category: Optional[str] = None) -> RecipeDoc:235        raise NotImplementedError236 237    # shared streaming loop238    def stream(self, sink: DualSink, options: Optional[StreamOptions] = None) -> int:239        opts = options or StreamOptions()240        self.log.info(241            f"Starting stream: limit={opts.limit} batch_size={opts.batch_size} "242            f"resume_file={opts.resume_file} sink={type(sink).__name__}"243        )244 245        processed = set()246        if opts.resume_file:247            resume_path = Path("data") / opts.resume_file  # <-- not ../data248            print(resume_path, 'resume_path')249            if resume_path.exists():                       # <-- open only if it exists250                with resume_path.open("r", encoding="utf-8") as f:251                    processed = {line.strip() for line in f if line.strip()}252            else:253                processed = set()254            self.log.info(f"[resume] {len(processed)} URLs already done")255 256        batch, saved = [], 0257        try:258            for i, url in enumerate(self.discover_urls(), 1):259                if opts.limit and i > opts.limit: break260                if not self.same_domain(url): continue261                if url in processed: continue262 263                try:264                    soup = self.fetch_soup(url)265                    doc = self.extract_recipe(soup, url)266                    doc.finalize()267                    batch.append(asdict(doc))268                except Exception as e:269                    self.log.warning(f"[skip] {url} -> {e}")270 271                if opts.resume_file:272                    resume_path = Path("data") / opts.resume_file273                    with open(resume_path, "a", encoding="utf-8") as rf:274                        rf.write(url + "\n")275 276                if len(batch) >= opts.batch_size:277                    self._apply_embeddings(batch)278                    sink.write_many(batch); saved += len(batch); batch = []279                    if opts.progress_callback: opts.progress_callback(saved)280                    self.log.info(f"[resume] {saved} URLs already done 1")281 282                if i % 25 == 0:283                    self.log.info(f"…processed {i}, saved {saved}")284 285                time.sleep(opts.delay)286 287            if batch:288                self._apply_embeddings(batch)289                sink.write_many(batch); saved += len(batch)290                if opts.progress_callback: opts.progress_callback(saved)291                self.log.info(f"[resume] {saved} URLs already done2 ")292        finally:293            sink.close()294 295        self.log.info(f"[done] saved {saved}")296        return saved297    298    @staticmethod299    def _field_to_text(val: Any) -> str:300        if isinstance(val, list):301            return "\n".join(str(x) for x in val)302        if val is None:303            return ""304        return str(val)305 306    def _gather_text(self, doc: Dict[str, Any], src: Any) -> str:307        if isinstance(src, tuple):308            parts: List[str] = []309            for f in src:310                t = self._field_to_text(doc.get(f))311                if t:312                    # Optional: label sections to help the embedder313                    label = "Ingredients" if f == "ingredients" else ("Instructions" if f == "instructions" else f)314                    parts.append(f"{label}:\n{t}")315            return "\n\n".join(parts)316        else:317            return self._field_to_text(doc.get(src))318        319    def _apply_embeddings(self, batch: List[Dict[str, Any]]) -> None:320        """321        Applies embeddings to specified fields in a batch of documents.322 323        For each (source_field, destination_field) pair in `self.embedding_fields`, this method:324        - Extracts the value from `source_field` in each document of the batch.325        - Converts the value to a string. If the value is a list, joins its elements with newlines.326        - Handles `None` values by converting them to empty strings.327        - Uses `self.embedder.encode` to generate embeddings for the processed texts.328        - Stores the resulting embedding vector in `destination_field` of each document.329 330        If `self.embedder`, `self.embedding_fields`, or `batch` is not set or empty, the method returns immediately.331 332        Args:333            batch (List[Dict[str, Any]]): A list of documents to process, where each document is a dictionary.334 335        Returns:336            None337        """338        if not self.embedder or not self.embedding_fields or not batch:339            return340        try:341            for src_spec, dst_field in self.embedding_fields:342                texts = [ self._gather_text(doc, src_spec) for doc in batch ]343                embs = self.embedder.encode(texts)344                for document, vec in zip(batch, embs):345                    document[dst_field] = vec346        except Exception as e:347            self.logger.warning(f"[stream error]: {e}")348 349