diff --git a/async_web_fetcher.py b/async_web_fetcher.py
index 965169e..5740363 100644
--- a/async_web_fetcher.py
+++ b/async_web_fetcher.py
@@ -65,6 +65,118 @@ class MediaItem:
height: Optional[int] = None
discovered_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
+
+class MediaMetadata:
+ """
+ Accumulates metadata from all sources without clobbering.
+
+ "Never clobber, always append" - collects ALL metadata from:
+ - img.alt, img.title
+ - a.title, a.text (link text)
+ - figcaption
+ - nearby headings
+ - page title/h1
+
+ Produces a combined searchable_text for full-text search.
+ """
+
+ def __init__(self):
+ self.titles: List[str] = []
+ self.alt_texts: List[str] = []
+ self.descriptions: List[str] = []
+ self.captions: List[str] = []
+ self.headings: List[str] = []
+ self.link_texts: List[str] = []
+ self.link_titles: List[str] = []
+
+ def _add_unique(self, lst: List[str], value: str, max_len: int = 500) -> None:
+ """Add value to list if non-empty and not duplicate."""
+ if value and value.strip():
+ clean = value.strip()[:max_len]
+ if clean not in lst:
+ lst.append(clean)
+
+ def add_img_alt(self, alt: str) -> None:
+ """Add img alt attribute."""
+ self._add_unique(self.alt_texts, alt)
+
+ def add_img_title(self, title: str) -> None:
+ """Add img title attribute."""
+ self._add_unique(self.titles, title)
+
+ def add_link_title(self, title: str) -> None:
+ """Add attribute."""
+ self._add_unique(self.link_titles, title)
+
+ def add_link_text(self, text: str) -> None:
+ """Add inner text."""
+ self._add_unique(self.link_texts, text)
+
+ def add_figcaption(self, caption: str) -> None:
+ """Add figcaption text."""
+ self._add_unique(self.captions, caption)
+
+ def add_heading(self, heading: str) -> None:
+ """Add nearby heading (h1-h6)."""
+ self._add_unique(self.headings, heading)
+
+ def add_description(self, desc: str) -> None:
+ """Add description (og:description, meta description, etc.)."""
+ self._add_unique(self.descriptions, desc)
+
+ def add_page_title(self, title: str) -> None:
+ """Add page title."""
+ self._add_unique(self.titles, title)
+
+ def get_best_title(self) -> Optional[str]:
+ """Get best title for display (first non-empty)."""
+ for lst in [self.titles, self.alt_texts, self.link_titles,
+ self.captions, self.link_texts, self.headings]:
+ if lst:
+ return lst[0]
+ return None
+
+ def get_best_alt(self) -> Optional[str]:
+ """Get best alt text for accessibility."""
+ if self.alt_texts:
+ return self.alt_texts[0]
+ return self.get_best_title()
+
+ def to_searchable_text(self) -> str:
+ """
+ Combine ALL collected metadata into searchable text.
+
+ This enables finding images by ANY associated text:
+ - "find images of cats" matches img alt="cute cat"
+ - "find images from blog post about python" matches page content
+ """
+ all_parts = []
+ # Dedupe while preserving order
+ seen = set()
+ for lst in [self.titles, self.alt_texts, self.descriptions,
+ self.captions, self.headings, self.link_texts, self.link_titles]:
+ for item in lst:
+ if item and item not in seen:
+ all_parts.append(item)
+ seen.add(item)
+ return ' | '.join(all_parts)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Export all collected metadata as dict."""
+ return {
+ 'titles': self.titles,
+ 'alt_texts': self.alt_texts,
+ 'descriptions': self.descriptions,
+ 'captions': self.captions,
+ 'headings': self.headings,
+ 'link_texts': self.link_texts,
+ 'link_titles': self.link_titles,
+ 'searchable_text': self.to_searchable_text(),
+ 'best_title': self.get_best_title(),
+ 'best_alt': self.get_best_alt(),
+ }
+
+
# PDF text extraction
try:
from pypdf import PdfReader
@@ -355,6 +467,16 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
elif name == 'keywords':
page_keywords = content[:500]
+ # Extract page content (body text) for full-text search
+ # This enables blog images to be searchable by post content
+ page_content = ''
+ body = soup.find('body')
+ if body:
+ # Remove script, style, nav, footer elements
+ for tag in body.find_all(['script', 'style', 'nav', 'footer', 'header', 'aside']):
+ tag.decompose()
+ page_content = body.get_text(separator=' ', strip=True)[:10000] # Limit to 10k chars
+
def get_context_for_element(element) -> dict:
"""Extract contextual metadata from surrounding HTML elements."""
context = {
@@ -363,6 +485,7 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
'figure_caption': None,
'nearby_heading': None,
'link_text': None,
+ 'link_title': None, # attribute
}
# Check if inside a with
@@ -372,12 +495,16 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
if figcaption:
context['figure_caption'] = figcaption.get_text(strip=True)[:200]
- # Check if inside an tag with text
+ # Check if inside an tag with text and/or title
link = element.find_parent('a')
if link:
link_text = link.get_text(strip=True)
if link_text and link_text != element.get('alt', ''):
context['link_text'] = link_text[:200]
+ # Also extract title attribute from tag (tooltip text)
+ link_title = link.get('title', '').strip()
+ if link_title:
+ context['link_title'] = link_title[:200]
# Find nearest heading (h1-h6) before this element
for heading_tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
@@ -394,11 +521,13 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
def infer_title(alt_text: str, title: str, context: dict) -> str:
"""Infer best title from available metadata."""
- # Priority: explicit title > alt text > figure caption > link text > nearby heading > page h1 > page title
+ # Priority: explicit title > alt text > link title > figure caption > link text > nearby heading > page h1 > page title
if title and title.strip():
return title.strip()
if alt_text and alt_text.strip() and len(alt_text) > 3:
return alt_text.strip()
+ if context.get('link_title'): # attribute
+ return context['link_title']
if context.get('figure_caption'):
return context['figure_caption']
if context.get('link_text'):
@@ -411,7 +540,7 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
return context['page_title']
return None
- def add_media(url: str, media_type: str, alt_text: str = None, title: str = None, width: int = None, height: int = None, element=None):
+ def add_media(url: str, media_type: str, alt_text: str = None, title: str = None, width: int = None, height: int = None, element=None, detail_page_url: str = None):
"""Helper to add media item if not already seen."""
if not url or url in seen_urls:
return
@@ -428,23 +557,75 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
# Get context from surrounding elements
context = get_context_for_element(element) if element else {'page_title': page_title, 'page_h1': page_h1}
- # Infer title if not provided
- inferred_title = infer_title(alt_text, title, context)
+ # Build metadata accumulator - "never clobber, always append"
+ metadata = MediaMetadata()
+
+ # Add from img element
+ if alt_text:
+ metadata.add_img_alt(alt_text)
+ if title:
+ metadata.add_img_title(title)
+
+ # Add from context
+ if context.get('figure_caption'):
+ metadata.add_figcaption(context['figure_caption'])
+ if context.get('link_text'):
+ metadata.add_link_text(context['link_text'])
+ if context.get('link_title'):
+ metadata.add_link_title(context['link_title'])
+ if context.get('nearby_heading'):
+ metadata.add_heading(context['nearby_heading'])
+ if context.get('page_title'):
+ metadata.add_page_title(context['page_title'])
+ if context.get('page_h1'):
+ metadata.add_heading(context['page_h1'])
+
+ # Add page-level metadata
+ if page_description:
+ metadata.add_description(page_description)
+
+ # Get best values for backward compatibility
+ best_title = metadata.get_best_title()
+ best_alt = metadata.get_best_alt()
+
+ # Check if this image is wrapped in an tag pointing to a detail page
+ # (Pinterest-style galleries where thumbnail links to detail page with canonical image)
+ resolved_detail_url = None
+ if element and not detail_page_url:
+ parent_link = element.find_parent('a', href=True)
+ if parent_link:
+ href = parent_link.get('href', '')
+ # Only consider internal links (not direct image links)
+ if href and not get_media_type_from_extension(href):
+ resolved_detail_url = urljoin(base_url, href)
+ # Only track same-domain detail pages
+ base_domain = urlparse(base_url).netloc
+ detail_domain = urlparse(resolved_detail_url).netloc
+ if base_domain != detail_domain:
+ resolved_detail_url = None
+ elif detail_page_url:
+ resolved_detail_url = urljoin(base_url, detail_page_url)
media_items.append({
'url': absolute_url,
'media_type': media_type,
- 'alt_text': alt_text or inferred_title, # Use inferred as alt if no alt
- 'title': inferred_title,
+ 'alt_text': best_alt,
+ 'title': best_title,
'width': width,
'height': height,
'source_page': base_url,
'page_title': page_title,
'page_description': page_description,
'page_keywords': page_keywords,
+ 'page_content': page_content, # Full text for blog post searchability
'figure_caption': context.get('figure_caption'),
'nearby_heading': context.get('nearby_heading'),
'link_text': context.get('link_text'),
+ 'link_title': context.get('link_title'), # attribute
+ 'detail_page_url': resolved_detail_url, # URL to fetch for canonical image + richer metadata
+ # Accumulated metadata for full-text search
+ 'searchable_text': metadata.to_searchable_text(),
+ 'metadata': metadata.to_dict(), # Full breakdown for debugging/analysis
})
# Extract from
tags
@@ -767,6 +948,231 @@ class AsyncWebFetcher:
logger.debug(f"HEAD check failed for {url}: {e}")
return None
+ async def resolve_canonical_image(
+ self,
+ detail_page_url: str,
+ thumbnail_url: str,
+ embedding_title: Optional[str] = None,
+ session: Optional[aiohttp.ClientSession] = None
+ ) -> Optional[Dict[str, Any]]:
+ """
+ Fetch a detail page and extract the canonical (full-res) image URL.
+
+ Universal Algorithm - no hardcoded strings, purely structural detection:
+
+ 1. REST pattern: If an
src matches detail_page_url minus last path segment
+ 2. Wrapped links: tags wrapping
where href has query params (versioned)
+ 3. Download links: pointing to media
+ 4. Path similarity:
src sharing path structure with detail page
+ 5. og:image fallback
+
+ Title: Prefers embedding_title (from listing page where image was found).
+ Falls back to detail page metadata only if embedding_title not provided.
+
+ Args:
+ detail_page_url: URL of the detail page to fetch
+ thumbnail_url: Original thumbnail URL (to avoid returning same URL)
+ embedding_title: Title from the page that linked here (preferred)
+ session: Optional aiohttp session to reuse
+
+ Returns:
+ Dict with 'canonical_url', 'title', 'description', 'og_image' or None
+ """
+ try:
+ close_session = session is None
+ if session is None:
+ session = aiohttp.ClientSession()
+
+ try:
+ # Check robots.txt
+ if not await self._can_fetch(detail_page_url):
+ return None
+
+ # Enforce crawl delay
+ domain = self._get_domain(detail_page_url)
+ await self._enforce_crawl_delay(domain)
+
+ async with session.get(
+ detail_page_url,
+ headers={"User-Agent": self.user_agent},
+ timeout=aiohttp.ClientTimeout(total=15),
+ allow_redirects=True
+ ) as response:
+ if response.status != 200:
+ return None
+
+ html = await response.text()
+ soup = BeautifulSoup(html, "html.parser")
+
+ result = {
+ 'detail_page_url': detail_page_url,
+ 'canonical_url': None,
+ 'embedding_title': embedding_title, # from listing page
+ 'detail_title': None, # from detail page
+ 'detail_content': None, # body text from detail page
+ 'title': None, # best available
+ 'description': None,
+ 'og_image': None,
+ }
+
+ # ========================================
+ # COLLECT BOTH TITLES (skeleton key approach)
+ # ========================================
+
+ # Always extract detail page title
+ detail_title = None
+
+ # 1. og:title
+ og_title = soup.find('meta', property='og:title')
+ if og_title:
+ detail_title = og_title.get('content', '').strip()
+
+ # 2. First image alt text
+ if not detail_title:
+ for img in soup.find_all('img', alt=True):
+ alt = img.get('alt', '').strip()
+ if alt and len(alt) > 2:
+ detail_title = alt
+ break
+
+ # 3. h1 tag
+ if not detail_title:
+ h1_tag = soup.find('h1')
+ if h1_tag:
+ detail_title = h1_tag.get_text(strip=True)
+
+ # 4. title tag
+ if not detail_title:
+ title_tag = soup.find('title')
+ if title_tag:
+ detail_title = title_tag.get_text(strip=True)
+
+ result['detail_title'] = detail_title
+ # Primary title: prefer embedding, fallback to detail
+ result['title'] = embedding_title or detail_title
+
+ # ========================================
+ # DESCRIPTION EXTRACTION
+ # ========================================
+ og_desc = soup.find('meta', property='og:description')
+ if og_desc:
+ result['description'] = og_desc.get('content', '')
+ if not result['description']:
+ meta_desc = soup.find('meta', attrs={'name': 'description'})
+ if meta_desc:
+ result['description'] = meta_desc.get('content', '')
+
+ # Extract og:image
+ og_image = soup.find('meta', property='og:image')
+ if og_image:
+ result['og_image'] = og_image.get('content', '')
+
+ # ========================================
+ # DETAIL CONTENT EXTRACTION (body text)
+ # ========================================
+ # Extract body text from detail page for full-text searchability
+ # This enables Pinterest-style galleries where detail pages
+ # have richer descriptions than thumbnails on listing pages
+ body = soup.find('body')
+ if body:
+ # Remove non-content elements
+ for tag in body.find_all(['script', 'style', 'nav', 'footer', 'header', 'aside']):
+ tag.decompose()
+ detail_content = body.get_text(separator=' ', strip=True)[:10000]
+ result['detail_content'] = detail_content
+
+ # ========================================
+ # CANONICAL URL DETECTION (universal patterns)
+ # ========================================
+ canonical_url = None
+ detail_parsed = urlparse(detail_page_url)
+
+ # Pattern 1: REST-style - check if img src matches URL minus last segment
+ # e.g., /media/ID/details has img pointing to /media/ID
+ path_segments = detail_parsed.path.rstrip('/').split('/')
+ if len(path_segments) > 1:
+ parent_path = '/'.join(path_segments[:-1])
+ parent_url = f"{detail_parsed.scheme}://{detail_parsed.netloc}{parent_path}"
+ for img in soup.find_all('img', src=True):
+ src = img.get('src', '')
+ full_src = urljoin(detail_page_url, src)
+ if full_src == parent_url or full_src.rstrip('/') == parent_url:
+ if full_src != thumbnail_url:
+ canonical_url = full_src
+ break
+
+ # Pattern 2: Find tags wrapping images with query params (versioned URLs)
+ # The href with ?param=value suggests a cache-busted/versioned canonical
+ if not canonical_url:
+ for a_tag in soup.find_all('a', href=True):
+ href = a_tag.get('href', '')
+ full_href = urljoin(detail_page_url, href)
+ # Must have query params (indicates versioned/timestamped)
+ if '?' not in full_href:
+ continue
+ # Must wrap or be near an image
+ img_inside = a_tag.find('img')
+ if not img_inside:
+ continue
+ # Check it's a media URL or same-domain endpoint
+ href_parsed = urlparse(full_href)
+ media_type = get_media_type_from_extension(full_href)
+ if media_type == 'image':
+ canonical_url = full_href
+ break
+ # Same domain with query params - likely image endpoint
+ if href_parsed.netloc == detail_parsed.netloc or not href_parsed.netloc:
+ canonical_url = full_href
+ break
+
+ # Pattern 3: attribute - semantic HTML for downloadable content
+ if not canonical_url:
+ for a_tag in soup.find_all('a', href=True, download=True):
+ href = a_tag.get('href', '')
+ if href:
+ full_href = urljoin(detail_page_url, href)
+ media_type = get_media_type_from_extension(full_href)
+ if media_type == 'image':
+ canonical_url = full_href
+ break
+
+ # Pattern 4: First image sharing path structure with detail page
+ if not canonical_url:
+ for img in soup.find_all('img', src=True):
+ src = img.get('src', '')
+ full_src = urljoin(detail_page_url, src)
+ if full_src == thumbnail_url:
+ continue
+ img_parsed = urlparse(full_src)
+ # Same host
+ if img_parsed.netloc == detail_parsed.netloc:
+ # Count shared path segments
+ detail_parts = detail_parsed.path.rstrip('/').split('/')
+ img_parts = img_parsed.path.rstrip('/').split('/')
+ common = sum(1 for d, i in zip(detail_parts, img_parts) if d == i)
+ # At least 2 shared segments suggests same resource
+ if common >= 2:
+ canonical_url = full_src
+ break
+
+ # Pattern 5: og:image fallback (if different from thumbnail)
+ if not canonical_url and result['og_image']:
+ if result['og_image'] != thumbnail_url:
+ canonical_url = result['og_image']
+
+ result['canonical_url'] = canonical_url
+
+ logger.info(f"Resolved canonical image from {detail_page_url}: {canonical_url}, title: {result['title'][:50] if result['title'] else 'None'}")
+ return result
+
+ finally:
+ if close_session:
+ await session.close()
+
+ except Exception as e:
+ logger.debug(f"Failed to resolve canonical image from {detail_page_url}: {e}")
+ return None
+
async def fetch_media(
self,
url: str,
diff --git a/database.py b/database.py
index b79d8ba..c912391 100644
--- a/database.py
+++ b/database.py
@@ -73,8 +73,13 @@ class Database:
page_title TEXT,
page_description TEXT,
page_keywords TEXT,
+ page_content TEXT,
alt_text TEXT,
link_text TEXT,
+ detail_page_uri TEXT,
+ detail_title TEXT,
+ detail_content TEXT,
+ searchable_text TEXT, -- Combined metadata for full-text search
crawl_job_id INTEGER,
discovered_at TEXT NOT NULL,
FOREIGN KEY (md5_hash) REFERENCES media(md5_hash),
@@ -148,10 +153,22 @@ class Database:
page_title: str = "",
page_description: str = "",
page_keywords: str = "",
+ page_content: str = "",
alt_text: str = "",
link_text: str = "",
+ detail_page_uri: str = "",
+ detail_title: str = "",
+ detail_content: str = "",
+ searchable_text: str = "",
) -> None:
- """Create a new media record and add source context."""
+ """Create a new media record and add source context.
+
+ Skeleton key approach: stores both embedding context (page_title, page_content from listing)
+ and detail context (detail_title, detail_content from detail page) for maximum searchability.
+
+ For blogs: page_content contains the post text so images are searchable by post content.
+ For galleries: detail_content contains the detail page text for richer metadata.
+ """
now = datetime.now(timezone.utc).isoformat()
async with aiosqlite.connect(self.db_path) as db:
@@ -169,10 +186,10 @@ class Database:
await db.execute(
"""
INSERT OR IGNORE INTO media_sources
- (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, alt_text, link_text, crawl_job_id, discovered_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, page_content, alt_text, link_text, detail_page_uri, detail_title, detail_content, searchable_text, crawl_job_id, discovered_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
- (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, alt_text, link_text, crawl_job_id, now)
+ (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, page_content, alt_text, link_text, detail_page_uri, detail_title, detail_content, searchable_text, crawl_job_id, now)
)
await db.commit()
@@ -185,19 +202,31 @@ class Database:
page_title: str = "",
page_description: str = "",
page_keywords: str = "",
+ page_content: str = "",
alt_text: str = "",
link_text: str = "",
+ detail_page_uri: str = "",
+ detail_title: str = "",
+ detail_content: str = "",
+ searchable_text: str = "",
crawl_job_id: int = None
) -> None:
- """Add another source context for an existing media hash."""
+ """Add another source context for an existing media hash.
+
+ Skeleton key approach: stores both embedding context (page_title, page_content from listing)
+ and detail context (detail_title, detail_content from detail page) for maximum searchability.
+
+ For blogs: page_content contains the post text so images are searchable by post content.
+ For galleries: detail_content contains the detail page text for richer metadata.
+ """
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR IGNORE INTO media_sources
- (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, alt_text, link_text, crawl_job_id, discovered_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, page_content, alt_text, link_text, detail_page_uri, detail_title, detail_content, searchable_text, crawl_job_id, discovered_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
- (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, alt_text, link_text, crawl_job_id, datetime.now(timezone.utc).isoformat())
+ (md5_hash, media_uri, page_uri, page_title, page_description, page_keywords, page_content, alt_text, link_text, detail_page_uri, detail_title, detail_content, searchable_text, crawl_job_id, datetime.now(timezone.utc).isoformat())
)
await db.commit()
diff --git a/neopig.py b/neopig.py
index 2ee16ec..18fadf4 100644
--- a/neopig.py
+++ b/neopig.py
@@ -316,19 +316,51 @@ class NeoPig:
job_id: int,
keywords: List[str]
):
- """Download and store a media item with page context."""
+ """Download and store a media item with page context.
+
+ Implements the skeleton key approach:
+ - If detail_page_url is set, resolves canonical image URL
+ - Collects both embedding_title (page_title) and detail_title
+ - Prefers canonical URL for download, falls back to original
+ """
media_uri = item['url']
page_uri = item.get('source_page', '')
media_type = item.get('media_type', 'unknown')
+ detail_page_url = item.get('detail_page_url')
- # Extract page context for searchability
+ # Extract page context for searchability (embedding context)
page_title = item.get('page_title', '')
page_description = item.get('page_description', '')
page_keywords = item.get('page_keywords', '')
+ page_content = item.get('page_content', '') # Blog post text for full-text search
alt_text = item.get('alt_text', '')
link_text = item.get('link_text', '')
+ searchable_text = item.get('searchable_text', '') # Combined metadata from accumulator
+
+ # Skeleton key: detail context (from detail page if Pinterest-style gallery)
+ detail_page_uri = ''
+ detail_title = ''
+ detail_content = ''
try:
+ # Crystal algorithm: resolve canonical image if this looks like a gallery thumbnail
+ if detail_page_url:
+ canonical_result = await self.fetcher.resolve_canonical_image(
+ detail_page_url=detail_page_url,
+ thumbnail_url=media_uri,
+ embedding_title=page_title, # Pass listing page title
+ )
+ if canonical_result:
+ detail_page_uri = detail_page_url
+ detail_title = canonical_result.get('detail_title', '')
+ detail_content = canonical_result.get('detail_content', '')
+ # Use canonical URL if found, otherwise keep thumbnail
+ if canonical_result.get('canonical_url'):
+ logger.debug(f"Canonical resolution: {media_uri} -> {canonical_result['canonical_url']}")
+ media_uri = canonical_result['canonical_url']
+ # Enrich metadata from detail page
+ if not alt_text and canonical_result.get('description'):
+ alt_text = canonical_result['description']
# Check if this exact media+page combo was already crawled
existing_hash = await self.db.check_media_uri_exists(media_uri, page_uri)
if existing_hash:
@@ -353,7 +385,7 @@ class NeoPig:
# Check if content already in vault
if await self.vault.exists(md5_hash):
- # Content exists, but add this new page context
+ # Content exists, but add this new page context with skeleton key
await self.db.add_media_source(
md5_hash=md5_hash,
media_uri=media_uri,
@@ -361,8 +393,13 @@ class NeoPig:
page_title=page_title,
page_description=page_description,
page_keywords=page_keywords,
+ page_content=page_content,
alt_text=alt_text,
link_text=link_text,
+ detail_page_uri=detail_page_uri,
+ detail_title=detail_title,
+ detail_content=detail_content,
+ searchable_text=searchable_text,
crawl_job_id=job_id,
)
self.stats['duplicates_skipped'] += 1
@@ -376,7 +413,7 @@ class NeoPig:
# Archive to domain media vault (git-tracked)
await self._archive_media_to_vault(media_uri, result['data'], page_uri)
- # Record in database with full context
+ # Record in database with full context and skeleton key
await self.db.create_media_record(
md5_hash=md5_hash,
media_uri=media_uri,
@@ -388,8 +425,13 @@ class NeoPig:
page_title=page_title,
page_description=page_description,
page_keywords=page_keywords,
+ page_content=page_content,
alt_text=alt_text,
link_text=link_text,
+ detail_page_uri=detail_page_uri,
+ detail_title=detail_title,
+ detail_content=detail_content,
+ searchable_text=searchable_text,
)
self.stats['media_downloaded'] += 1