Merge pull request #208 from iliaal/fix/date-parsing

fix(github): reject garbage in _parse_date; consolidate date parsing
This commit is contained in:
Matt Van Horn
2026-04-13 22:21:35 -04:00
committed by GitHub
4 changed files with 43 additions and 51 deletions
+9 -8
View File
@@ -17,7 +17,7 @@ import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from . import log from . import dates, log
from .query import extract_core_subject from .query import extract_core_subject
from .relevance import token_overlap_relevance from .relevance import token_overlap_relevance
@@ -106,13 +106,14 @@ def _parse_repo_from_url(html_url: str) -> str:
def _parse_date(iso_str: Optional[str]) -> Optional[str]: def _parse_date(iso_str: Optional[str]) -> Optional[str]:
"""Extract YYYY-MM-DD from ISO 8601 datetime string.""" """Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD.
if not iso_str:
return None Returns None for non-date input. GitHub's API always emits ISO 8601
try: (e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so
return iso_str[:10] garbage input gets rejected instead of silently sliced.
except (IndexError, TypeError): """
return None dt = dates.parse_date(iso_str)
return dt.strftime("%Y-%m-%d") if dt else None
def _compute_relevance( def _compute_relevance(
+8 -20
View File
@@ -12,7 +12,6 @@ import sys
import time import time
from collections import Counter from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
def _first_of(*values, default=None): def _first_of(*values, default=None):
@@ -22,7 +21,7 @@ def _first_of(*values, default=None):
return v return v
return default return default
from . import http, log from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit" SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
@@ -206,27 +205,16 @@ def _parse_date(value) -> Optional[str]:
Global search returns ``created_at`` as an ISO string Global search returns ``created_at`` as an ISO string
(e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns (e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns
``created_utc`` as a Unix timestamp. Handle both. ``created_utc`` as a Unix timestamp. dates.parse_date() handles both,
plus edge cases like Z suffix and +0000 (no colon) offset.
Falsy inputs (None, "", 0) return None, matching the original behavior
where a Unix timestamp of 0 meant "no date" rather than epoch 0.
""" """
if not value: if not value:
return None return None
# ISO-8601 string (contains 'T' or '-') dt = dates.parse_date(str(value))
if isinstance(value, str) and ("T" in value or "-" in value): return dt.strftime("%Y-%m-%d") if dt else None
try:
# Strip trailing offset variations (+0000, Z) for fromisoformat
clean = value.replace("Z", "+00:00")
if clean.endswith("+0000"):
clean = clean[:-5] + "+00:00"
dt = datetime.fromisoformat(clean)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
# Unix timestamp (int or float or numeric string)
try:
dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
def _extract_subreddit_name(value: Any) -> str: def _extract_subreddit_name(value: Any) -> str:
+10 -23
View File
@@ -9,10 +9,9 @@ API docs: https://scrapecreators.com/docs
import math import math
import re import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from . import http, log from . import dates, http, log
from .relevance import token_overlap_relevance as _compute_relevance from .relevance import token_overlap_relevance as _compute_relevance
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads" SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
@@ -52,29 +51,17 @@ def _extract_core_subject(topic: str) -> str:
def _parse_date(item: Dict[str, Any]) -> Optional[str]: def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from Threads item to YYYY-MM-DD. """Parse date from Threads item to YYYY-MM-DD.
Tries common timestamp fields: taken_at (unix), created_at (ISO), Tries common timestamp fields in order: taken_at and create_time
and falls back to any date-like string field. (unix timestamps in Meta APIs), then created_at, published_at, and
date (ISO 8601 strings). dates.parse_date() handles both.
""" """
# Unix timestamp (taken_at is common in Meta APIs) for key in ("taken_at", "create_time", "created_at", "published_at", "date"):
for key in ("taken_at", "create_time"):
ts = item.get(key)
if ts:
try:
from . import dates
return dates.timestamp_to_date(int(ts))
except (ValueError, TypeError):
pass
# ISO 8601 string
for key in ("created_at", "published_at", "date"):
val = item.get(key) val = item.get(key)
if val and isinstance(val, str): if val is None:
try: continue
dt = datetime.fromisoformat(val.replace("Z", "+00:00")) dt = dates.parse_date(str(val))
return dt.strftime("%Y-%m-%d") if dt:
except (ValueError, TypeError): return dt.strftime("%Y-%m-%d")
pass
return None return None
+16
View File
@@ -56,6 +56,22 @@ class TestParseDate(unittest.TestCase):
def test_empty(self): def test_empty(self):
self.assertIsNone(github._parse_date("")) self.assertIsNone(github._parse_date(""))
def test_rejects_garbage(self):
"""The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
self.assertIsNone(github._parse_date("hello world"))
self.assertIsNone(github._parse_date("not-a-date"))
self.assertIsNone(github._parse_date("abcdefghij"))
def test_rejects_invalid_date_values(self):
"""An out-of-range date like 2026-99-99 is not a real date."""
self.assertIsNone(github._parse_date("2026-99-99"))
def test_iso_with_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
def test_iso_with_no_colon_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
class TestSearchGithub(unittest.TestCase): class TestSearchGithub(unittest.TestCase):
@patch.dict("os.environ", {}, clear=True) @patch.dict("os.environ", {}, clear=True)