Repository: scrapinghub/dateparser

Issue:
support a span of time in `search_dates`
I have some use cases as the following

```
messages received for the past month 
messages received last week
```

With potential ambiguity, I'm expecting the first one to return a list of two dates: `today - 30d` and `today`. Similarly for the second one, i'm expecting `last Sunday` to `last Monday`

Both should probably be configurable for the convenience. Like `default_start_of_week` and `default_days_in_month`





Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 2d729aacd..17cedddad 100644
--- a/README.rst
+++ b/README.rst
@@ -58,6 +58,7 @@ Key Features
 -  `Search
    dates <https://dateparser.readthedocs.io/en/latest/introduction.html#search-for-dates-in-longer-chunks-of-text>`__
    in longer texts.
+-  Time span detection for expressions like "past month", "last week".
 
 Online demo
 -----------
diff --git a/dateparser/conf.py b/dateparser/conf.py
index 1548b7c28..73eab5f63 100644
--- a/dateparser/conf.py
+++ b/dateparser/conf.py
@@ -27,6 +27,9 @@ class Settings:
     * `SKIP_TOKENS`
     * `NORMALIZE`
     * `RETURN_TIME_AS_PERIOD`
+    * `RETURN_TIME_SPAN`
+    * `DEFAULT_START_OF_WEEK`
+    * `DEFAULT_DAYS_IN_MONTH`
     * `PARSERS`
     * `DEFAULT_LANGUAGES`
     * `LANGUAGE_DETECTION_CONFIDENCE_THRESHOLD`
@@ -229,6 +232,14 @@ def check_settings(settings):
         "CACHE_SIZE_LIMIT": {
             "type": int,
         },
+        "RETURN_TIME_SPAN": {"type": bool},
+        "DEFAULT_START_OF_WEEK": {
+            "values": ("monday", "sunday"),
+            "type": str,
+        },
+        "DEFAULT_DAYS_IN_MONTH": {
+            "type": int,
+        },
     }
 
     modified_settings = settings._mod_settings  # check only modified settings
diff --git a/dateparser/search/search.py b/dateparser/search/search.py
index 751cb4756..3d38d2792 100644
--- a/dateparser/search/search.py
+++ b/dateparser/search/search.py
@@ -1,4 +1,5 @@
 from collections.abc import Set
+from datetime import datetime
 
 import regex as re
 
@@ -7,6 +8,7 @@
 from dateparser.date import DateDataParser
 from dateparser.languages.loader import LocaleDataLoader
 from dateparser.search.text_detection import FullTextLanguageDetector
+from dateparser.utils.time_spans import detect_time_span, generate_time_span
 
 RELATIVE_REG = re.compile("(ago|in|from now|tomorrow|today|yesterday)")
 
@@ -185,8 +187,23 @@ def search_parse(self, shortname, text, settings):
             translated=translated,
             settings=settings,
         )
+
+        results = list(zip(substrings, [i[0]["date_obj"] for i in parsed]))
+
+        if getattr(settings, "RETURN_TIME_SPAN", False):
+            span_info = detect_time_span(text)
+            if span_info:
+                base_date = getattr(settings, "RELATIVE_BASE", None) or datetime.now()
+                start_date, end_date = generate_time_span(
+                    span_info, base_date, settings
+                )
+
+                matched_text = span_info["matched_text"]
+                results.append((matched_text + " (start)", start_date))
+                results.append((matched_text + " (end)", end_date))
+
         parser._settings = Settings()
-        return list(zip(substrings, [i[0]["date_obj"] for i in parsed]))
+        return results
 
 
 class DateSearchWithDetection:
diff --git a/dateparser/utils/time_spans.py b/dateparser/utils/time_spans.py
new file mode 100644
index 000000000..fcf7e06be
--- /dev/null
+++ b/dateparser/utils/time_spans.py
@@ -0,0 +1,152 @@
+"""
+Utilities for handling time spans and date ranges.
+"""
+
+import re
+from datetime import datetime, timedelta
+from dateutil.relativedelta import relativedelta
+
+
+def get_week_start(date, start_of_week="monday"):
+    """Get the start of the week for a given date."""
+    if start_of_week == "monday":
+        days_back = date.weekday()
+    else:  # sunday
+        days_back = (date.weekday() + 1) % 7
+
+    return date - timedelta(days=days_back)
+
+
+def get_week_end(date, start_of_week="monday"):
+    """Get the end of the week for a given date."""
+    week_start = get_week_start(date, start_of_week)
+    return week_start + timedelta(days=6)
+
+
+def detect_time_span(text):
+    """Detect time span expressions in text and return span information."""
+    span_patterns = [
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:past|last|previous)\s+month\b",
+            "type": "month",
+            "direction": "past",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:past|last|previous)\s+week\b",
+            "type": "week",
+            "direction": "past",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:past|last|previous)\s+(\d+)\s+days?\b",
+            "type": "days",
+            "direction": "past",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:past|last|previous)\s+(\d+)\s+weeks?\b",
+            "type": "weeks",
+            "direction": "past",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:past|last|previous)\s+(\d+)\s+months?\b",
+            "type": "months",
+            "direction": "past",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:next|coming|following)\s+month\b",
+            "type": "month",
+            "direction": "future",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:next|coming|following)\s+week\b",
+            "type": "week",
+            "direction": "future",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:next|coming|following)\s+(\d+)\s+days?\b",
+            "type": "days",
+            "direction": "future",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:next|coming|following)\s+(\d+)\s+weeks?\b",
+            "type": "weeks",
+            "direction": "future",
+        },
+        {
+            "pattern": r"\b(?:for\s+the\s+|during\s+the\s+|in\s+the\s+)?(?:next|coming|following)\s+(\d+)\s+months?\b",
+            "type": "months",
+            "direction": "future",
+        },
+    ]
+
+    for pattern_info in span_patterns:
+        match = re.search(pattern_info["pattern"], text, re.IGNORECASE)
+        if match:
+            result = {
+                "type": pattern_info["type"],
+                "direction": pattern_info["direction"],
+                "matched_text": match.group(0),
+                "start_pos": match.start(),
+                "end_pos": match.end(),
+            }
+
+            if match.groups():
+                result["number"] = int(match.group(1))
+
+            return result
+
+    return None
+
+
+def generate_time_span(span_info, base_date=None, settings=None):
+    """Generate start and end dates for a time span."""
+    if base_date is None:
+        base_date = datetime.now()
+
+    if settings is None:
+        start_of_week = "monday"
+        days_in_month = 30
+    else:
+        start_of_week = getattr(settings, "DEFAULT_START_OF_WEEK", "monday")
+        days_in_month = getattr(settings, "DEFAULT_DAYS_IN_MONTH", 30)
+
+    span_type = span_info["type"]
+    direction = span_info["direction"]
+    number = span_info.get("number", 1)
+
+    if direction == "past":
+        end_date = base_date
+
+        if span_type == "month":
+            start_date = end_date - relativedelta(days=days_in_month)
+        elif span_type == "week":
+            week_start = get_week_start(end_date, start_of_week)
+            start_date = week_start - timedelta(days=7)
+            end_date = week_start - timedelta(days=1)
+        elif span_type == "days":
+            start_date = end_date - timedelta(days=number)
+        elif span_type == "weeks":
+            start_date = end_date - timedelta(weeks=number)
+        elif span_type == "months":
+            start_date = end_date - relativedelta(months=number)
+        else:
+            start_date = end_date - timedelta(days=1)
+
+    else:
+        start_date = base_date
+
+        if span_type == "month":
+            end_date = start_date + relativedelta(days=days_in_month)
+        elif span_type == "week":
+            week_start = get_week_start(start_date, start_of_week)
+            start_date = week_start + timedelta(days=7)
+            end_date = start_date + timedelta(days=6)
+        elif span_type == "days":
+            end_date = start_date + timedelta(days=number)
+        elif span_type == "weeks":
+            end_date = start_date + timedelta(weeks=number)
+        elif span_type == "months":
+            end_date = start_date + relativedelta(months=number)
+        else:
+            end_date = start_date + timedelta(days=1)
+
+    return (start_date, end_date)
diff --git a/dateparser_data/settings.py b/dateparser_data/settings.py
index 6ce7849cc..edf3c4a71 100644
--- a/dateparser_data/settings.py
+++ b/dateparser_data/settings.py
@@ -26,6 +26,10 @@
     "DEFAULT_LANGUAGES": [],
     # Optional language detection
     "LANGUAGE_DETECTION_CONFIDENCE_THRESHOLD": 0.5,
+    # Time span settings
+    "RETURN_TIME_SPAN": False,
+    "DEFAULT_START_OF_WEEK": "monday",
+    "DEFAULT_DAYS_IN_MONTH": 30,
     # Other settings
     "RETURN_TIME_AS_PERIOD": False,
     "PARSERS": default_parsers,
diff --git a/docs/introduction.rst b/docs/introduction.rst
index ff8f11fff..bc3445f8d 100644
--- a/docs/introduction.rst
+++ b/docs/introduction.rst
@@ -211,6 +211,17 @@ You can extract dates from longer strings of text. They are returned as list of
    :members: search_dates
    :noindex:
 
+Time Span Detection
+-------------------
+
+The `search_dates` function can detect time spans from expressions like "past month", "last week", etc. When `RETURN_TIME_SPAN` is enabled, it returns start and end dates for the detected period.
+
+.. code-block:: python
+
+    >>> search_dates("Messages from the past month", settings={'RETURN_TIME_SPAN': True})
+    [('past month (start)', datetime.datetime(2024, 11, 7, 0, 0)), 
+     ('past month (end)', datetime.datetime(2024, 12, 7, 23, 59, 59, 999999))]
+
 Advanced Usage
 ==============
 If you need more control over what is being parser check the :ref:`settings` section as well as the :ref:`using-datedataparser` section.
diff --git a/docs/settings.rst b/docs/settings.rst
index 18bc0a598..c2d5bd724 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -112,6 +112,27 @@ For example, assuming current date is `June 16, 2015`:
     >>> parse('tomorrow', settings={'RELATIVE_BASE': datetime.datetime(2020, 1, 1)})
     datetime.datetime(2020, 1, 2, 0, 0)
 
+``RETURN_TIME_SPAN``
+~~~~~~~~~~~~~~~~~~~~
+
+**default**: ``False``
+
+When enabled, `search_dates` detects time span expressions (e.g., "past month", "last week") and returns start/end dates instead of single dates.
+
+``DEFAULT_START_OF_WEEK``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**default**: ``'monday'``
+
+Sets which day starts the week for time span calculations. Options: ``'monday'``, ``'sunday'``.
+
+``DEFAULT_DAYS_IN_MONTH``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**default**: ``30``
+
+Sets the number of days to use for "past month" and similar expressions in time span detection.
+
 ``STRICT_PARSING``: defaults to ``False``.
 
 When set to ``True`` if missing any of ``day``, ``month`` or ``year`` parts, it does not return any result altogether.:


Test patch:
diff --git a/tests/test_search.py b/tests/test_search.py
index 8457f127e..fef747079 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -1,4 +1,5 @@
 import datetime
+from datetime import timedelta
 
 import pytz
 from parameterized import param, parameterized
@@ -1213,3 +1214,122 @@ def test_search_dates_multi_word_expression(
             )
         ]
         self.assertEqual(result, expected)
+
+    def test_search_dates_time_span_past_month(self):
+        """Test search_dates with 'past month' time span."""
+        text = "messages received for the past month"
+        settings = {
+            "RETURN_TIME_SPAN": True,
+            "RELATIVE_BASE": datetime.datetime(2025, 2, 15, 12, 0, 0),
+            "DEFAULT_DAYS_IN_MONTH": 30,
+        }
+
+        result = search_dates(text, settings=settings)
+
+        self.assertIsNotNone(result)
+        self.assertIsInstance(result, list)
+        if result is not None:
+            self.assertGreaterEqual(len(result), 2)
+
+            span_results = [r for r in result if "(start)" in r[0] or "(end)" in r[0]]
+            self.assertEqual(len(span_results), 2)
+
+            start_result = next(r for r in span_results if "(start)" in r[0])
+            end_result = next(r for r in span_results if "(end)" in r[0])
+            self.assertEqual(end_result[1], datetime.datetime(2025, 2, 15, 12, 0, 0))
+
+            expected_start = datetime.datetime(2025, 2, 15, 12, 0, 0) - timedelta(
+                days=30
+            )
+            self.assertEqual(start_result[1], expected_start)
+
+    def test_search_dates_time_span_last_week(self):
+        """Test search_dates with 'last week' time span."""
+        text = "messages received last week"
+        settings = {
+            "RETURN_TIME_SPAN": True,
+            "RELATIVE_BASE": datetime.datetime(2025, 2, 18, 12, 0, 0),  # Tuesday
+            "DEFAULT_START_OF_WEEK": "monday",
+        }
+
+        result = search_dates(text, settings=settings)
+
+        self.assertIsNotNone(result)
+        self.assertIsInstance(result, list)
+        if result is not None:
+            self.assertGreaterEqual(len(result), 2)
+
+            span_results = [r for r in result if "(start)" in r[0] or "(end)" in r[0]]
+            self.assertEqual(len(span_results), 2)
+
+            start_result = next(r for r in span_results if "(start)" in r[0])
+            end_result = next(r for r in span_results if "(end)" in r[0])
+            expected_start = datetime.datetime(2025, 2, 10, 12, 0, 0)
+            expected_end = datetime.datetime(2025, 2, 16, 12, 0, 0)
+
+            self.assertEqual(start_result[1], expected_start)
+            self.assertEqual(end_result[1], expected_end)
+
+    def test_search_dates_time_span_custom_start_of_week(self):
+        """Test search_dates with custom start_of_week setting."""
+        text = "messages received last week"
+        settings = {
+            "RETURN_TIME_SPAN": True,
+            "RELATIVE_BASE": datetime.datetime(2025, 2, 18, 12, 0, 0),  # Tuesday
+            "DEFAULT_START_OF_WEEK": "sunday",  # Custom start of week
+        }
+
+        result = search_dates(text, settings=settings)
+        self.assertIsNotNone(result)
+        self.assertIsInstance(result, list)
+        if result is not None:
+            self.assertGreaterEqual(len(result), 2)
+
+            span_results = [r for r in result if "(start)" in r[0] or "(end)" in r[0]]
+            self.assertEqual(len(span_results), 2)
+
+            start_result = next(r for r in span_results if "(start)" in r[0])
+            end_result = next(r for r in span_results if "(end)" in r[0])
+            expected_start = datetime.datetime(2025, 2, 9, 12, 0, 0)
+            expected_end = datetime.datetime(2025, 2, 15, 12, 0, 0)
+
+            self.assertEqual(start_result[1], expected_start)
+            self.assertEqual(end_result[1], expected_end)
+
+    def test_search_dates_time_span_custom_days_in_month(self):
+        """Test search_dates with custom days_in_month setting."""
+        text = "messages received for the past month"
+        settings = {
+            "RETURN_TIME_SPAN": True,
+            "RELATIVE_BASE": datetime.datetime(2025, 2, 15, 12, 0, 0),
+            "DEFAULT_DAYS_IN_MONTH": 28,  # Custom month length
+        }
+
+        result = search_dates(text, settings=settings)
+
+        self.assertIsNotNone(result)
+        self.assertIsInstance(result, list)
+        if result is not None:
+            self.assertGreaterEqual(len(result), 2)
+
+            span_results = [r for r in result if "(start)" in r[0] or "(end)" in r[0]]
+            self.assertEqual(len(span_results), 2)
+
+            start_result = next(r for r in span_results if "(start)" in r[0])
+            end_result = next(r for r in span_results if "(end)" in r[0])
+            self.assertEqual(end_result[1], datetime.datetime(2025, 2, 15, 12, 0, 0))
+
+            expected_start = datetime.datetime(2025, 2, 15, 12, 0, 0) - timedelta(
+                days=28
+            )
+            self.assertEqual(start_result[1], expected_start)
+
+    def test_search_dates_time_span_disabled_by_default(self):
+        """Test that time span functionality is disabled by default."""
+        text = "messages received for the past month"
+
+        result = search_dates(text)
+
+        if result:
+            span_results = [r for r in result if "(start)" in r[0] or "(end)" in r[0]]
+            self.assertEqual(len(span_results), 0)