Merge pull request #208 from iliaal/fix/date-parsing
fix(github): reject garbage in _parse_date; consolidate date parsing
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user