25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

ssr.py 4.2 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. import html
  2. import json
  3. import logging
  4. import re
  5. from services.dates import parse_datetime
  6. logger = logging.getLogger(__name__)
  7. # JSON-LD structured data blocks (schema.org) that most news sites emit for SEO —
  8. # and Next.js's serialized page state. In both cases the headlines (and dates)
  9. # are already present in the HTML, so no JavaScript execution is required.
  10. _JSON_LD_RE = re.compile(
  11. r"""<script\b[^>]*?\btype=(["'])application/ld\+json\1[^>]*>(.*?)</script>""",
  12. re.IGNORECASE | re.DOTALL,
  13. )
  14. _NEXT_DATA_RE = re.compile(
  15. r"""<script\b[^>]*?\bid=(["'])__NEXT_DATA__\1[^>]*>(.*?)</script>""",
  16. re.IGNORECASE | re.DOTALL,
  17. )
  18. _ARTICLE_TYPES = {
  19. 'newsarticle', 'article', 'report', 'analysisnewsarticle',
  20. 'opinionnewsarticle', 'reviewnewsarticle', 'blogposting',
  21. 'liveblogposting', 'backgroundnewsarticle', 'reportagenewsarticle',
  22. }
  23. _URL_KEYS = ('url', 'href', 'link', 'canonicalUrl', 'slug', 'uri')
  24. def _has_url(obj):
  25. for key in _URL_KEYS:
  26. value = obj.get(key)
  27. if isinstance(value, str) and value.strip():
  28. return True
  29. return False
  30. def _schema_type(obj):
  31. """Last path segment of an object's ``@type``, lowercased (handles URLs)."""
  32. if not isinstance(obj, dict):
  33. return ''
  34. t = obj.get('@type')
  35. if isinstance(t, list):
  36. t = t[0] if t else None
  37. if not isinstance(t, str):
  38. return ''
  39. return t.strip().rstrip('/').rsplit('/', 1)[-1].lower()
  40. def _iter_json_scripts(content):
  41. """Yield unescaped JSON bodies of JSON-LD and Next.js SSR ``<script>`` blocks."""
  42. if not content or not isinstance(content, str):
  43. return
  44. for pattern in (_JSON_LD_RE, _NEXT_DATA_RE):
  45. for match in pattern.finditer(content):
  46. body = match.group(2)
  47. if body and body.strip():
  48. yield html.unescape(body.strip())
  49. def _collect(obj, out):
  50. """Recursively append ``(headline, published_at)`` for article-like objects.
  51. Headlines are recognized by three signals:
  52. * ``headline`` — article-specific in schema.org, trusted directly.
  53. * ``title`` + a url-ish sibling — the common Next.js SSR shape, where
  54. ``title`` alone is too ambiguous (section labels use it too).
  55. * ``name`` — trusted only with an article-type/date/author signal.
  56. """
  57. if isinstance(obj, dict):
  58. stype = _schema_type(obj)
  59. headline = None
  60. raw = obj.get('headline')
  61. if isinstance(raw, str) and raw.strip():
  62. headline = raw.strip()
  63. else:
  64. raw_title = obj.get('title')
  65. if isinstance(raw_title, str) and raw_title.strip() and _has_url(obj):
  66. headline = raw_title.strip()
  67. else:
  68. raw_name = obj.get('name')
  69. if isinstance(raw_name, str) and raw_name.strip() and (
  70. stype in _ARTICLE_TYPES or obj.get('datePublished') or obj.get('author')
  71. ):
  72. headline = raw_name.strip()
  73. if headline:
  74. published = obj.get('datePublished') or obj.get('dateModified') or obj.get('date')
  75. published_at = parse_datetime(published) if isinstance(published, str) else None
  76. out.append((headline, published_at))
  77. for value in obj.values():
  78. _collect(value, out)
  79. elif isinstance(obj, list):
  80. for value in obj:
  81. _collect(value, out)
  82. def extract_headlines(content):
  83. """Best-effort extraction of ``(headline, published_at)`` from embedded JSON.
  84. Walks JSON-LD and Next.js SSR data for article-like objects and returns a
  85. de-duplicated list of tuples, where ``published_at`` is a ``datetime`` or
  86. ``None``.
  87. """
  88. results = []
  89. seen = set()
  90. for body in _iter_json_scripts(content):
  91. try:
  92. data = json.loads(body)
  93. except (json.JSONDecodeError, ValueError, TypeError):
  94. continue
  95. collected = []
  96. _collect(data, collected)
  97. for headline, published in collected:
  98. key = headline.casefold()
  99. if key in seen:
  100. continue
  101. seen.add(key)
  102. results.append((headline, published))
  103. logger.info("Extracted %d headline(s) from embedded JSON", len(results))
  104. return results