返回 last30days-skill
normalize.py
根目录 / skills / last30days / scripts / lib / normalize.py
1 """Normalization of source-specific payloads into the v3 generic item model."""
2
3 from __future__ import annotations
4
5 from typing import Any
6 from urllib.parse import urlparse
7
8 from . import dates, schema
9
10
11 def filter_by_date_range(
12 items: list[schema.SourceItem],
13 from_date: str,
14 to_date: str,
15 require_date: bool = False,
16 ) -> list[schema.SourceItem]:
17 """Keep only items within the requested window."""
18 filtered: list[schema.SourceItem] = []
19 for item in items:
20 if not item.published_at:
21 if not require_date:
22 filtered.append(item)
23 continue
24 if item.published_at < from_date or item.published_at > to_date:
25 continue
26 filtered.append(item)
27 return filtered
28
29
30 def normalize_source_items(
31 source: str,
32 items: list[dict[str, Any]],
33 from_date: str,
34 to_date: str,
35 freshness_mode: str = "balanced_recent",
36 ) -> list[schema.SourceItem]:
37 """Normalize raw source items, filter by date range, with evergreen fallback for how_to queries."""
38 source = source.lower()
39 normalizers = {
40 "reddit": _normalize_reddit,
41 "x": _normalize_x,
42 "youtube": _normalize_youtube,
43 "tiktok": lambda s, i, idx, fd, td: _normalize_shortform_video(
44 s, i, idx, fd, td, "TK", "TikTok post"
45 ),
46 "instagram": lambda s, i, idx, fd, td: _normalize_shortform_video(
47 s, i, idx, fd, td, "IG", "Instagram reel"
48 ),
49 "hackernews": _normalize_hackernews,
50 "stocktwits": _normalize_stocktwits,
51 "dripstack": _normalize_dripstack,
52 "bluesky": lambda s, i, idx, fd, td: _normalize_microblog(
53 s, i, idx, fd, td, "BS", "Bluesky post"
54 ),
55 "truthsocial": lambda s, i, idx, fd, td: _normalize_microblog(
56 s, i, idx, fd, td, "TS", "Truth Social post"
57 ),
58 "threads": lambda s, i, idx, fd, td: _normalize_microblog(
59 s, i, idx, fd, td, "TH", "Threads post"
60 ),
61 "xquik": _normalize_x,
62 "pinterest": _normalize_pinterest,
63 "polymarket": _normalize_polymarket,
64 "digg": _normalize_digg,
65 "arxiv": _normalize_arxiv,
66 "techmeme": _normalize_techmeme,
67 "trustpilot": _normalize_trustpilot,
68 "grounding": _normalize_grounding,
69 "xiaohongshu": _normalize_grounding,
70 "github": _normalize_github,
71 "perplexity": _normalize_grounding,
72 "jobs": _normalize_jobs,
73 "linkedin": _normalize_linkedin,
74 }
75 normalizer = normalizers.get(source)
76 if normalizer is None:
77 raise ValueError(f"Unsupported source: {source}")
78 normalized = [
79 normalizer(source, item, index, from_date, to_date)
80 for index, item in enumerate(items)
81 ]
82 if source == "jobs":
83 # A careers board is a snapshot of CURRENTLY OPEN roles. An open posting
84 # is current evidence regardless of when it was posted, so date-windowing
85 # it drops still-open roles (the "Founding Research Scientist, Human
86 # Simulation" miss: 26 open roles filtered to 3 by a 30-day window).
87 # Keep the full board; recency is annotated, not used to drop.
88 return normalized
89 require_date = source == "grounding"
90 filtered = filter_by_date_range(
91 normalized, from_date, to_date, require_date=require_date
92 )
93 if filtered:
94 return filtered
95 if freshness_mode == "evergreen_ok" and source == "youtube":
96 if require_date:
97 return [item for item in normalized if item.published_at]
98 return normalized
99 return filtered
100
101
102 def _remap_comments(
103 raw: list[Any],
104 score_keys: tuple[str, ...],
105 excerpt_keys: tuple[str, ...],
106 *,
107 preserve_absent_score: bool = False,
108 ) -> list[dict[str, Any]]:
109 """Normalize comments from any source into the shared Reddit-compatible shape.
110
111 Downstream code (signals._top_comment_score, render._top_comments_list,
112 entity_extract, rerank) all expect `score` and `excerpt`. This helper maps
113 per-source field names (YT: likes/text, TikTok: digg_count/text) onto that
114 shape while preserving author/date/url passthrough.
115
116 Sources that distinguish an absent vote from a measured zero can opt into
117 preserving the absent value as ``None``.
118 """
119 out: list[dict[str, Any]] = []
120 for raw_c in raw:
121 if not isinstance(raw_c, dict):
122 continue
123 score = _first_present(
124 raw_c,
125 score_keys,
126 default=None if preserve_absent_score else 0,
127 )
128 excerpt = _first_present(raw_c, excerpt_keys, default="")
129 if score is None and preserve_absent_score:
130 normalized_score = None
131 else:
132 try:
133 normalized_score = int(score or 0)
134 except (TypeError, ValueError):
135 normalized_score = 0
136 entry: dict[str, Any] = {
137 "score": normalized_score,
138 "excerpt": str(excerpt or "")[:400],
139 "author": str(raw_c.get("author") or ""),
140 "date": str(raw_c.get("date") or ""),
141 }
142 if raw_c.get("url"):
143 entry["url"] = str(raw_c["url"])
144 out.append(entry)
145 return out
146
147
148 def _first_present(d: dict[str, Any], keys: tuple[str, ...], default: Any) -> Any:
149 for key in keys:
150 if key in d and d[key] not in (None, ""):
151 return d[key]
152 return default
153
154
155 def _join_comment_excerpts(
156 top_comments: list[Any],
157 key: str,
158 limit: int = 3,
159 ) -> str:
160 """Space-join the `key` field from the first `limit` dict-shaped comments."""
161 return " ".join(
162 str(comment.get(key) or "").strip()
163 for comment in top_comments[:limit]
164 if isinstance(comment, dict)
165 )
166
167
168 def _domain_from_url(url: str) -> str | None:
169 if not url:
170 return None
171 domain = urlparse(url).netloc.strip().lower()
172 return domain or None
173
174
175 def _date_confidence(
176 item: dict[str, Any], from_date: str, to_date: str, default: str = "low"
177 ) -> str:
178 if item.get("date_confidence"):
179 return str(item["date_confidence"])
180 date_value = item.get("date")
181 if not date_value:
182 return default
183 return dates.get_date_confidence(str(date_value), from_date, to_date)
184
185
186 def _source_item(
187 *,
188 item_id: str,
189 source: str,
190 title: str,
191 body: str,
192 url: str,
193 published_at: str | None,
194 date_confidence: str,
195 relevance_hint: float,
196 why_relevant: str,
197 author: str | None = None,
198 container: str | None = None,
199 engagement: dict[str, float | int] | None = None,
200 snippet: str = "",
201 metadata: dict[str, Any] | None = None,
202 ) -> schema.SourceItem:
203 return schema.SourceItem(
204 item_id=item_id,
205 source=source,
206 title=title.strip() or body.strip()[:160] or item_id,
207 body=body.strip(),
208 url=url.strip(),
209 author=(author or "").strip() or None,
210 container=(container or "").strip() or None,
211 published_at=published_at,
212 date_confidence=date_confidence,
213 engagement=engagement or {},
214 relevance_hint=max(0.0, min(1.0, float(relevance_hint or 0.0))),
215 why_relevant=why_relevant.strip(),
216 snippet=snippet.strip(),
217 metadata=metadata or {},
218 )
219
220
221 def _normalize_stocktwits(
222 source: str,
223 item: dict[str, Any],
224 index: int,
225 from_date: str,
226 to_date: str,
227 ) -> schema.SourceItem:
228 meta = item.get("metadata") or {}
229 return _source_item(
230 item_id=str(item.get("id") or f"ST{index + 1}"),
231 source=source,
232 title=str(item.get("title") or ""),
233 body=str(item.get("snippet") or ""),
234 url=str(item.get("url") or ""),
235 author=str(item.get("author") or "") or None,
236 container=str(meta.get("symbol") or "") or None,
237 published_at=item.get("date"),
238 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
239 engagement=item.get("engagement") or {},
240 relevance_hint=item.get("relevance", 0.7),
241 why_relevant=str(item.get("why_relevant") or ""),
242 snippet=str(item.get("snippet") or "")[:400],
243 metadata=meta, # carries sentiment + symbol-level bull/bear aggregate
244 )
245
246
247 def _normalize_dripstack(
248 source: str,
249 item: dict[str, Any],
250 index: int,
251 from_date: str,
252 to_date: str,
253 ) -> schema.SourceItem:
254 """Normalizer for DripStack newsletter search results.
255
256 DripStack returns article metadata from paid financial newsletters.
257 No engagement signal — ranking relies on DripStack's own relevanceScore
258 (0-100, normalized to 0-1) plus recency. The publication name serves as
259 author/attribution (e.g. "SemiAnalysis", "Bloomberg").
260 """
261 meta = item.get("metadata") or {}
262 return _source_item(
263 item_id=str(item.get("id") or f"DS{index + 1}"),
264 source=source,
265 title=str(item.get("title") or ""),
266 body=str(item.get("body") or "")
267 or str(item.get("snippet") or "")
268 or str(item.get("title") or ""),
269 url=str(item.get("url") or ""),
270 author=str(item.get("author") or "") or None,
271 container=str(meta.get("publication_slug") or "") or None,
272 published_at=item.get("date"),
273 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
274 engagement={},
275 relevance_hint=item.get("relevance", 0.5),
276 why_relevant=str(item.get("why_relevant") or ""),
277 snippet=str(item.get("snippet") or "")[:400],
278 metadata={
279 **meta,
280 "publication_slug": meta.get("publication_slug"),
281 },
282 )
283
284
285 def _normalize_reddit(
286 source: str,
287 item: dict[str, Any],
288 index: int,
289 from_date: str,
290 to_date: str,
291 ) -> schema.SourceItem:
292 top_comments = item.get("top_comments") or []
293 comment_text = _join_comment_excerpts(top_comments, "excerpt")
294 body = "\n".join(
295 part
296 for part in [
297 str(item.get("title") or "").strip(),
298 str(item.get("selftext") or "").strip(),
299 comment_text,
300 ]
301 if part
302 )
303 return _source_item(
304 item_id=str(item.get("id") or f"R{index + 1}"),
305 source=source,
306 title=str(item.get("title") or ""),
307 body=body,
308 url=str(item.get("url") or ""),
309 author=None,
310 container=str(item.get("subreddit") or ""),
311 published_at=item.get("date"),
312 date_confidence=_date_confidence(item, from_date, to_date),
313 engagement=item.get("engagement") or {},
314 relevance_hint=item.get("relevance", 0.5),
315 why_relevant=str(item.get("why_relevant") or ""),
316 snippet=comment_text or str(item.get("selftext") or "")[:400],
317 metadata={
318 "top_comments": top_comments,
319 "comment_insights": item.get("comment_insights") or [],
320 },
321 )
322
323
324 def _normalize_x(
325 source: str,
326 item: dict[str, Any],
327 index: int,
328 from_date: str,
329 to_date: str,
330 ) -> schema.SourceItem:
331 text = str(item.get("text") or "").strip()
332 mentioned = item.get("mentioned_handles") or []
333 return _source_item(
334 item_id=str(item.get("id") or f"X{index + 1}"),
335 source=source,
336 title=text[:140] or f"X post {index + 1}",
337 body=text,
338 url=str(item.get("url") or ""),
339 author=str(item.get("author_handle") or "").lstrip("@"),
340 published_at=item.get("date"),
341 date_confidence=_date_confidence(item, from_date, to_date),
342 engagement=item.get("engagement") or {},
343 relevance_hint=item.get("relevance", 0.5),
344 why_relevant=str(item.get("why_relevant") or ""),
345 metadata={"mentioned_handles": list(mentioned)} if mentioned else {},
346 )
347
348
349 def _normalize_jobs(
350 source: str,
351 item: dict[str, Any],
352 index: int,
353 from_date: str,
354 to_date: str,
355 ) -> schema.SourceItem:
356 description = str(item.get("description") or item.get("snippet") or "").strip()
357 title = str(item.get("title") or "").strip()
358 department = str(item.get("department") or "").strip()
359 location = str(item.get("location") or "").strip()
360 body = "\n".join(
361 part for part in [title, department, location, description] if part
362 )
363 provider = str(item.get("provider") or "").strip()
364 return _source_item(
365 item_id=str(item.get("id") or f"J{index + 1}"),
366 source=source,
367 title=title or f"Job posting {index + 1}",
368 body=body,
369 url=str(item.get("url") or ""),
370 author=provider or None,
371 container=department or None,
372 published_at=item.get("date"),
373 date_confidence=_date_confidence(item, from_date, to_date),
374 engagement={"open_roles": 1},
375 relevance_hint=item.get("relevance", 0.65),
376 why_relevant=str(item.get("why_relevant") or "Public job posting"),
377 snippet=description[:500],
378 metadata={
379 "provider": provider,
380 "department": department,
381 "departments": item.get("departments")
382 or ([department] if department else []),
383 "location": location,
384 "offices": item.get("offices") or [],
385 "board_token": item.get("board_token") or "",
386 "source_url": item.get("source_url") or "",
387 "source_domain": item.get("source_domain")
388 or _domain_from_url(str(item.get("url") or ""))
389 or "",
390 },
391 )
392
393
394 def _normalize_youtube(
395 source: str,
396 item: dict[str, Any],
397 index: int,
398 from_date: str,
399 to_date: str,
400 ) -> schema.SourceItem:
401 transcript = str(item.get("transcript_snippet") or "").strip()
402 description = str(item.get("description") or "").strip()
403 title = str(item.get("title") or "").strip()
404 highlights = item.get("transcript_highlights") or []
405 metadata: dict[str, Any] = {}
406 if highlights:
407 metadata["transcript_highlights"] = highlights
408 if item.get("captions_disabled"):
409 # Surfaced for quality_nudge: uploader disabled captions, so this
410 # video should be subtracted from the degraded-transcript-ratio
411 # denominator (it was never going to produce a transcript).
412 metadata["captions_disabled"] = True
413 metadata["top_comments"] = _remap_comments(
414 item.get("top_comments") or [],
415 score_keys=("score", "likes"),
416 excerpt_keys=("excerpt", "text"),
417 )
418 return _source_item(
419 item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"),
420 source=source,
421 title=title,
422 body="\n".join(part for part in [title, description, transcript] if part),
423 url=str(item.get("url") or ""),
424 author=str(item.get("channel_name") or ""),
425 published_at=item.get("date"),
426 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
427 engagement=item.get("engagement") or {},
428 relevance_hint=item.get("relevance", 0.5),
429 why_relevant=str(item.get("why_relevant") or ""),
430 snippet=transcript,
431 metadata=metadata,
432 )
433
434
435 def _normalize_shortform_video(
436 source: str,
437 item: dict[str, Any],
438 index: int,
439 from_date: str,
440 to_date: str,
441 id_prefix: str,
442 default_title: str,
443 ) -> schema.SourceItem:
444 """Shared normalizer for TikTok and Instagram (identical structure)."""
445 caption = str(item.get("caption_snippet") or "").strip()
446 text = str(item.get("text") or "").strip()
447 return _source_item(
448 item_id=str(item.get("id") or f"{id_prefix}{index + 1}"),
449 source=source,
450 title=text[:140] or caption[:140] or f"{default_title} {index + 1}",
451 body="\n".join(part for part in [text, caption] if part),
452 url=str(item.get("url") or ""),
453 author=str(item.get("author_name") or ""),
454 published_at=item.get("date"),
455 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
456 engagement=item.get("engagement") or {},
457 relevance_hint=item.get("relevance", 0.5),
458 why_relevant=str(item.get("why_relevant") or ""),
459 snippet=caption,
460 metadata={
461 "hashtags": item.get("hashtags") or [],
462 "top_comments": _remap_comments(
463 item.get("top_comments") or [],
464 # Instagram comments use comment_like_count as the vote field
465 # (ScrapeCreators /v2/instagram/post/comments); digg_count/likes
466 # kept for shape compatibility.
467 score_keys=("score", "comment_like_count", "digg_count", "likes"),
468 excerpt_keys=("excerpt", "text"),
469 ),
470 },
471 )
472
473
474 def _normalize_pinterest(
475 source: str,
476 item: dict[str, Any],
477 index: int,
478 from_date: str,
479 to_date: str,
480 ) -> schema.SourceItem:
481 """Normalizer for Pinterest pins (visual content with descriptions).
482
483 Saves are the primary engagement signal, analogous to likes/upvotes.
484 """
485 description = str(item.get("description") or "").strip()
486 return _source_item(
487 item_id=str(item.get("pin_id") or item.get("id") or f"PI{index + 1}"),
488 source=source,
489 title=description[:140] or f"Pinterest pin {index + 1}",
490 body=description,
491 url=str(item.get("url") or ""),
492 author=str(item.get("author") or ""),
493 container=str(item.get("board") or ""),
494 published_at=item.get("date"),
495 date_confidence=_date_confidence(item, from_date, to_date, default="low"),
496 engagement=item.get("engagement") or {},
497 relevance_hint=item.get("relevance", 0.5),
498 why_relevant=str(item.get("why_relevant") or ""),
499 snippet=description[:400],
500 )
501
502
503 def _normalize_hackernews(
504 source: str,
505 item: dict[str, Any],
506 index: int,
507 from_date: str,
508 to_date: str,
509 ) -> schema.SourceItem:
510 # HN comments arrive as {author, text, points}; downstream code keys on
511 # score/excerpt, so remap here exactly as the YouTube and TikTok normalisers
512 # do. Without this the per-source floor in render._top_comments_list reads a
513 # `score` that is never present and rejects every HN comment.
514 top_comments = _remap_comments(
515 item.get("top_comments") or [],
516 score_keys=("points", "score"),
517 excerpt_keys=("text", "excerpt"),
518 preserve_absent_score=True,
519 )
520 comment_text = _join_comment_excerpts(top_comments, "excerpt")
521 title = str(item.get("title") or "").strip()
522 body = "\n".join(
523 part
524 for part in [title, str(item.get("text") or "").strip(), comment_text]
525 if part
526 )
527 return _source_item(
528 item_id=str(item.get("id") or f"HN{index + 1}"),
529 source=source,
530 title=title or f"HN story {index + 1}",
531 body=body,
532 url=str(item.get("url") or item.get("hn_url") or ""),
533 author=str(item.get("author") or ""),
534 container="Hacker News",
535 published_at=item.get("date"),
536 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
537 engagement=item.get("engagement") or {},
538 relevance_hint=item.get("relevance", 0.5),
539 why_relevant=str(item.get("why_relevant") or ""),
540 snippet=comment_text,
541 metadata={
542 "hn_url": item.get("hn_url"),
543 "top_comments": top_comments,
544 "comment_insights": item.get("comment_insights") or [],
545 },
546 )
547
548
549 def _normalize_microblog(
550 source: str,
551 item: dict[str, Any],
552 index: int,
553 from_date: str,
554 to_date: str,
555 id_prefix: str,
556 default_title: str,
557 ) -> schema.SourceItem:
558 """Shared normalizer for Bluesky and Truth Social (identical structure)."""
559 text = str(item.get("text") or "").strip()
560 return _source_item(
561 item_id=str(item.get("id") or f"{id_prefix}{index + 1}"),
562 source=source,
563 title=text[:140] or f"{default_title} {index + 1}",
564 body=text,
565 url=str(item.get("url") or ""),
566 author=str(item.get("handle") or item.get("author_handle") or "").lstrip("@"),
567 published_at=item.get("date"),
568 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
569 engagement=item.get("engagement") or {},
570 relevance_hint=item.get("relevance", 0.5),
571 why_relevant=str(item.get("why_relevant") or ""),
572 metadata={"display_name": item.get("display_name")},
573 )
574
575
576 def _normalize_digg(
577 source: str,
578 item: dict[str, Any],
579 index: int,
580 from_date: str,
581 to_date: str,
582 ) -> schema.SourceItem:
583 """Normalizer for Digg AI 1000 clusters.
584
585 Each cluster is one item. The TLDR carries the most useful body for
586 rerank and synthesis. Top-ranked X posts attached at search time are
587 passed through under metadata['posts'] so render can emit them as
588 inline 'via Digg' quotes.
589 """
590 title = str(item.get("title") or "").strip()
591 tldr = str(item.get("tldr") or "").strip()
592 body = "\n\n".join(part for part in [title, tldr] if part)
593 posts = item.get("posts") or []
594 if not isinstance(posts, list):
595 posts = []
596 cluster_url_id = str(item.get("id") or f"DG{index + 1}")
597 return _source_item(
598 item_id=cluster_url_id,
599 source=source,
600 title=title or f"Digg cluster {index + 1}",
601 body=body,
602 url=str(item.get("url") or f"https://di.gg/ai/{cluster_url_id}"),
603 author="",
604 container="Digg",
605 published_at=item.get("date"),
606 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
607 engagement=item.get("engagement") or {},
608 relevance_hint=item.get("relevance", 0.5),
609 why_relevant=str(item.get("why_relevant") or ""),
610 snippet=tldr[:400],
611 metadata={
612 "clusterUrlId": cluster_url_id,
613 "tldr": tldr,
614 "rank": (item.get("engagement") or {}).get("rank"),
615 "uniqueAuthors": (item.get("engagement") or {}).get("uniqueAuthors"),
616 "postCount": (item.get("engagement") or {}).get("postCount"),
617 "firstPostAge": item.get("first_post_age"),
618 "posts": posts,
619 },
620 )
621
622
623 def _normalize_arxiv(
624 source: str,
625 item: dict[str, Any],
626 index: int,
627 from_date: str,
628 to_date: str,
629 ) -> schema.SourceItem:
630 """Normalizer for arXiv papers.
631
632 The abstract (summary) is the body that feeds rerank and synthesis. arXiv
633 has no engagement signal, so engagement is empty and ranking leans on
634 relevance and recency.
635 """
636 title = str(item.get("title") or "").strip()
637 summary = str(item.get("summary") or "").strip()
638 body = "\n\n".join(part for part in [title, summary] if part)
639 authors = item.get("authors") or []
640 if not isinstance(authors, list):
641 authors = []
642 paper_id = str(item.get("id") or f"AX{index + 1}")
643 return _source_item(
644 item_id=paper_id,
645 source=source,
646 title=title or f"arXiv paper {index + 1}",
647 body=body,
648 url=str(item.get("url") or ""),
649 author=str(item.get("author") or "") or None,
650 container="arXiv",
651 published_at=item.get("date"),
652 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
653 engagement={},
654 relevance_hint=item.get("relevance", 0.5),
655 why_relevant=str(item.get("why_relevant") or ""),
656 snippet=summary[:400],
657 metadata={
658 "authors": authors,
659 "summary": summary,
660 },
661 )
662
663
664 def _normalize_techmeme(
665 source: str,
666 item: dict[str, Any],
667 index: int,
668 from_date: str,
669 to_date: str,
670 ) -> schema.SourceItem:
671 """Normalizer for Techmeme headlines.
672
673 The headline is both title and body (Techmeme carries no abstract). The
674 publication is the container/author. No engagement signal in the search
675 shape, so ranking leans on relevance and recency.
676 """
677 title = str(item.get("title") or "").strip()
678 source_name = str(item.get("source_name") or "").strip()
679 return _source_item(
680 item_id=str(item.get("id") or f"TM{index + 1}"),
681 source=source,
682 title=title or f"Techmeme headline {index + 1}",
683 body=title,
684 url=str(item.get("url") or ""),
685 author=source_name or None,
686 container=source_name or "Techmeme",
687 published_at=item.get("date"),
688 date_confidence=_date_confidence(item, from_date, to_date, default="low"),
689 engagement={},
690 relevance_hint=item.get("relevance", 0.5),
691 why_relevant=str(item.get("why_relevant") or ""),
692 snippet=title[:400],
693 metadata={
694 "publication": source_name,
695 },
696 )
697
698
699 def _normalize_trustpilot(
700 source: str,
701 item: dict[str, Any],
702 index: int,
703 from_date: str,
704 to_date: str,
705 ) -> schema.SourceItem:
706 """Normalizer for Trustpilot company sentiment.
707
708 One item per company. The AI summary (already balanced positive/negative)
709 is the body. TrustScore and review count are engagement and metadata.
710 """
711 title = str(item.get("title") or "").strip()
712 name = str(item.get("name") or "").strip()
713 summary = str(item.get("summary") or "").strip()
714 body = "\n\n".join(part for part in [title, summary] if part)
715 return _source_item(
716 item_id=str(item.get("id") or f"TP{index + 1}"),
717 source=source,
718 title=title
719 or (f"{name} on Trustpilot" if name else f"Trustpilot reviews {index + 1}"),
720 body=body,
721 url=str(item.get("url") or ""),
722 author=name or None,
723 container="Trustpilot",
724 published_at=item.get("date"),
725 date_confidence=_date_confidence(item, from_date, to_date, default="low"),
726 engagement=item.get("engagement") or {},
727 relevance_hint=item.get("relevance", 0.6),
728 why_relevant=str(item.get("why_relevant") or ""),
729 snippet=summary[:400],
730 metadata={
731 "name": name,
732 "trustScore": item.get("trustScore"),
733 "reviewCount": item.get("reviewCount"),
734 "aiSummary": summary,
735 },
736 )
737
738
739 def _normalize_polymarket(
740 source: str,
741 item: dict[str, Any],
742 index: int,
743 from_date: str,
744 to_date: str,
745 ) -> schema.SourceItem:
746 title = str(item.get("title") or "").strip()
747 question = str(item.get("question") or "").strip()
748 engagement = {
749 "volume": item.get("volume1mo") or item.get("volume24hr") or 0,
750 "liquidity": item.get("liquidity") or 0,
751 }
752 return _source_item(
753 item_id=str(item.get("event_id") or item.get("id") or f"PM{index + 1}"),
754 source=source,
755 title=title or question or f"Polymarket event {index + 1}",
756 body="\n".join(
757 part
758 for part in [title, question, str(item.get("price_movement") or "")]
759 if part
760 ),
761 url=str(item.get("url") or ""),
762 author=None,
763 container="Polymarket",
764 published_at=item.get("date"),
765 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
766 engagement=engagement,
767 relevance_hint=item.get("relevance", 0.5),
768 why_relevant=str(item.get("why_relevant") or ""),
769 snippet=str(item.get("price_movement") or ""),
770 metadata={
771 "event_id": item.get("event_id"),
772 "question": question,
773 "end_date": item.get("end_date"),
774 "outcome_prices": item.get("outcome_prices") or [],
775 "outcomes_remaining": item.get("outcomes_remaining"),
776 },
777 )
778
779
780 def _normalize_github(
781 source: str,
782 item: dict[str, Any],
783 index: int,
784 from_date: str,
785 to_date: str,
786 ) -> schema.SourceItem:
787 title = str(item.get("title") or "").strip()
788 snippet_text = str(item.get("snippet") or "").strip()
789 top_comments = item.get("metadata", {}).get("top_comments") or []
790 comment_text = _join_comment_excerpts(top_comments, "excerpt")
791 body = "\n".join(part for part in [title, snippet_text, comment_text] if part)
792 metadata = item.get("metadata") or {}
793 return _source_item(
794 item_id=str(item.get("id") or f"GH{index + 1}"),
795 source=source,
796 title=title or f"GitHub item {index + 1}",
797 body=body,
798 url=str(item.get("url") or ""),
799 author=str(item.get("author") or ""),
800 container=str(item.get("container") or ""),
801 published_at=item.get("date"),
802 date_confidence=_date_confidence(item, from_date, to_date, default="high"),
803 engagement=item.get("engagement") or {},
804 relevance_hint=item.get("relevance", 0.5),
805 why_relevant=str(item.get("why_relevant") or ""),
806 snippet=comment_text or snippet_text[:400],
807 metadata={
808 "top_comments": top_comments,
809 "labels": metadata.get("labels") or [],
810 "state": metadata.get("state", ""),
811 "is_pr": metadata.get("is_pr", False),
812 },
813 )
814
815
816 def _normalize_grounding(
817 source: str,
818 item: dict[str, Any],
819 index: int,
820 from_date: str,
821 to_date: str,
822 ) -> schema.SourceItem:
823 title = str(item.get("title") or "").strip()
824 snippet = str(item.get("snippet") or "").strip()
825 url = str(item.get("url") or "").strip()
826 return _source_item(
827 item_id=str(item.get("id") or f"W{index + 1}"),
828 source=source,
829 title=title or _domain_from_url(url) or f"Web result {index + 1}",
830 body="\n".join(part for part in [title, snippet] if part),
831 url=url,
832 author=None,
833 container=str(item.get("source_domain") or _domain_from_url(url) or ""),
834 published_at=item.get("date"),
835 date_confidence=_date_confidence(item, from_date, to_date),
836 engagement=item.get("engagement") or {},
837 relevance_hint=item.get("relevance", 0.5),
838 why_relevant=str(item.get("why_relevant") or ""),
839 snippet=snippet,
840 metadata=item.get("metadata") or {},
841 )
842
843
844 def _normalize_linkedin(
845 source: str,
846 item: dict[str, Any],
847 index: int,
848 from_date: str,
849 to_date: str,
850 ) -> schema.SourceItem:
851 """Normalizer for LinkedIn posts and articles via ScrapeCreators.
852
853 A LinkedIn article (Pulse long-form, under a /pulse/ URL) is treated as
854 high signal: it ranks above ordinary posts. Detection is belt-and-suspenders
855 — honor the parser's `is_article` flag, and re-derive from the URL so an
856 article still ranks high even if the flag wasn't set upstream.
857 """
858 text = str(item.get("text") or "").strip()
859 author = str(item.get("author") or "").strip()
860 url = str(item.get("url") or "").strip()
861 is_article = bool(item.get("is_article")) or "/pulse/" in url.lower()
862 kind = "article" if is_article else "post"
863 default_relevance = 0.9 if is_article else 0.5
864 return _source_item(
865 item_id=str(item.get("id") or f"LI{index + 1}"),
866 source=source,
867 title=text[:140] or f"LinkedIn {kind} {index + 1}",
868 body=text,
869 url=url,
870 author=author,
871 container="LinkedIn Article" if is_article else "LinkedIn",
872 published_at=item.get("date"),
873 date_confidence=_date_confidence(item, from_date, to_date, default="medium"),
874 engagement=item.get("engagement") or {},
875 relevance_hint=item.get("relevance", default_relevance),
876 why_relevant=str(item.get("why_relevant") or ""),
877 snippet=text[:200],
878 metadata={"author_display": author, "is_article": is_article},
879 )
880
880 lines PYTHON