
Gdelt Doc Search
- 33 installs
- 7 repo stars
- Updated August 4, 2026
- tiangong-ai/skills
Helps with ai & agent building tasks.
About
gdelt-doc-search is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- gdelt-doc-search
- AI & Agent Building
- AI-coding skill
Gdelt Doc Search by the numbers
- 33 all-time installs (skills.sh)
- Ranked #8,975 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tiangong-ai/skills --skill gdelt-doc-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 33 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 4, 2026 |
| Repository | tiangong-ai/skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
GDELT DOC Search
Core Goal
- Execute atomic DOC API searches against
https://api.gdeltproject.org/api/v2/doc/doc. - Support topic/domain retrieval via explicit query syntax.
- Support both relative windows (
timespan) and absolute UTC windows (STARTDATETIME,ENDDATETIME). - Return structured JSON envelopes and optionally write raw response bytes.
- Keep runtime observable with structured logs and optional log file.
Required Environment
- Configure runtime by environment variables (see
references/env.md). - Start from
assets/config.example.env. - Load env values before running commands:
set -a
source assets/config.example.env
set +aWorkflow
1. Validate effective configuration.
python3 scripts/gdelt_doc_search.py check-config --pretty2. Run a relative-window DOC search.
python3 scripts/gdelt_doc_search.py search \
--query '("climate change" OR pollution)' \
--mode artlist \
--format json \
--timespan 1day \
--max-records 50 \
--pretty3. Run an absolute-window timeline query.
python3 scripts/gdelt_doc_search.py search \
--query '("climate change" OR pollution) sourcecountry:us' \
--mode timelinevolraw \
--format json \
--start-datetime 20260301000000 \
--end-datetime 20260308000000 \
--timeline-smooth 5 \
--pretty4. Persist raw API payload to a file for downstream tools.
python3 scripts/gdelt_doc_search.py search \
--query '(wildfire OR drought)' \
--mode artlist \
--format json \
--timespan 1week \
--output ./data/gdelt-doc/wildfire.json \
--prettyBuilt-in Robustness
- Apply retry with exponential backoff on transient HTTP/network failures.
- Respect
Retry-Afterwhen present on retriable responses. - Throttle request frequency with a minimum interval between requests.
- Validate query/time parameter combinations before remote calls.
- Validate DOC constraints (
MAXRECORDS<=250,TIMELINESMOOTH<=30). - Emit JSON results while writing operational logs to stderr and optional log file.
Scope Decision
- Keep only DOC API retrieval in this skill.
- Keep atomic operations only; do not add internal scheduler/polling loops.
References
references/env.mdreferences/gdelt-data-sources.mdreferences/gdelt-doc-search.mdreferences/gdelt-limitations.mdreferences/openclaw-chaining-templates.md
Script
scripts/gdelt_doc_search.py
OpenClaw Invocation Compatibility
- Keep skill trigger metadata in
name,description, andagents/openai.yaml. - Invoke in prompts with
$gdelt-doc-search. - Keep the skill atomic: one query execution per invocation.
- Use script parameters for retrieval conditions (
--query,--mode,--format,--timespanor--start-datetime/--end-datetime). - If you need polling, let OpenClaw agent orchestrate repeated invocations externally (scheduler/loop), not inside this skill.
OpenClaw Prompt Templates
Use these templates directly in OpenClaw and only replace bracketed placeholders.
1. Recon (config and endpoint check)
Use $gdelt-doc-search.
Run:
python3 scripts/gdelt_doc_search.py check-config --pretty
Return only the JSON result.2. Search (relative window)
Use $gdelt-doc-search.
Run:
python3 scripts/gdelt_doc_search.py search \
--query '[QUERY_EXPRESSION]' \
--mode [MODE] \
--format json \
--timespan [TIMESPAN] \
--max-records [N] \
--pretty
Return only the JSON result.3. Validate (absolute window and output persistence)
Use $gdelt-doc-search.
Run:
python3 scripts/gdelt_doc_search.py search \
--query '[QUERY_EXPRESSION]' \
--mode [MODE] \
--format json \
--start-datetime [YYYYMMDDHHMMSS] \
--end-datetime [YYYYMMDDHHMMSS] \
--output [OUTPUT_FILE] \
--pretty
Check command exit code and bytes_written > 0.
Return JSON plus one-line pass/fail verdict.interface:
display_name: "GDELT DOC Search"
short_description: "Search GDELT DOC API with retries and logs"
default_prompt: "Use $gdelt-doc-search to run atomic GDELT DOC API searches with query, mode, format, and time-window parameters."
GDELT_DOC_API_BASE_URL=https://api.gdeltproject.org/api/v2/doc/doc
GDELT_TIMEOUT_SECONDS=60
GDELT_MAX_RETRIES=4
GDELT_RETRY_BACKOFF_SECONDS=1.5
GDELT_RETRY_BACKOFF_MULTIPLIER=2.0
GDELT_MIN_REQUEST_INTERVAL_SECONDS=5.0
GDELT_USER_AGENT=gdelt-doc-search/1.0
Environment Variables
Use environment variables for all runtime behavior. The script does not hardcode secrets or mutable operational settings.
GDELT_DOC_API_BASE_URL- Default:
https://api.gdeltproject.org/api/v2/doc/doc - Base URL for DOC API retrieval.
GDELT_TIMEOUT_SECONDS- Default:
60 - HTTP timeout for each request.
GDELT_MAX_RETRIES- Default:
4 - Retry count for transient failures (total attempts = retries + 1).
GDELT_RETRY_BACKOFF_SECONDS- Default:
1.5 - Initial retry delay in seconds.
GDELT_RETRY_BACKOFF_MULTIPLIER- Default:
2.0 - Exponential factor for retry delay growth.
GDELT_MIN_REQUEST_INTERVAL_SECONDS- Default:
5.0 - Minimum sleep interval between requests to reduce burst traffic.
GDELT_USER_AGENT- Default:
gdelt-doc-search/1.0 - Value sent in HTTP
User-Agent.
Example:
export GDELT_DOC_API_BASE_URL="https://api.gdeltproject.org/api/v2/doc/doc"
export GDELT_TIMEOUT_SECONDS="60"
export GDELT_MAX_RETRIES="4"
export GDELT_RETRY_BACKOFF_SECONDS="1.5"
export GDELT_RETRY_BACKOFF_MULTIPLIER="2.0"
export GDELT_MIN_REQUEST_INTERVAL_SECONDS="5.0"
export GDELT_USER_AGENT="gdelt-doc-search/1.0"GDELT DOC API Source Notes
This skill operates on GDELT's query API layer, not raw table file exports.
What This Skill Uses
- Endpoint:
https://api.gdeltproject.org/api/v2/doc/doc- Retrieval styles via
mode: - Article lists (for example
artlist) - Timelines (for example
timelinevol,timelinevolraw,timelinetone) - Other DOC modes documented by GDELT
What This Skill Does Not Use
- It does not fetch raw GDELT file tables (
*.export.CSV.zip,*.mentions.CSV.zip,*.gkg.csv.zip). - It does not run BigQuery SQL in this skill.
Official Links
- DOC API guide:
https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/- GDELT 2.0 overview:
https://blog.gdeltproject.org/gdelt-2-0-our-global-world-in-realtime/
GDELT DOC API Search Parameter Model
This note explains how to express domain retrieval for the DOC API.
Parameter Form
DOC API retrieval is parameterized HTTP query, not SQL:
- Base endpoint:
https://api.gdeltproject.org/api/v2/doc/doc- Required:
query(string with GDELT query syntax)mode(for exampleartlist,timelinevol,timelinetone)format(for examplejson,csv,html,rss,rssarchive)- Time window:
timespan(for example1h,3days,1week,3months)- or
STARTDATETIME+ENDDATETIME(YYYYMMDDHHMMSS) - Optional:
MAXRECORDS(1-250, mainlyartlistand image collage modes)TIMELINESMOOTH(0-30, timeline modes)sort(for exampledatedesc)- extra parameters as needed
Domain Retrieval Expression
Domain retrieval is based on query syntax over indexed content/operators.
- Not SQL (no
SELECT ... FROM ... WHERE ...). - Not regex retrieval as a primary query language.
- Not natural-language-to-query translation by an official GDELT LLM service.
You provide explicit query expressions:
("climate change" OR "global warming" OR pollution OR "carbon emissions")("wildlife crime" OR poaching OR "illegal fishing" OR "wildlife trade")You can combine DOC operators in query, for example sourcelang:, sourcecountry:, imagetag:.
Script Mapping
Relative-window search:
python3 scripts/gdelt_doc_search.py search \
--query '("climate change" OR "global warming") sourcecountry:us' \
--mode artlist \
--format json \
--timespan 1week \
--max-records 100 \
--prettyAbsolute-window search:
python3 scripts/gdelt_doc_search.py search \
--query '(pollution OR smog)' \
--mode timelinevolraw \
--format json \
--start-datetime 20260301000000 \
--end-datetime 20260308000000 \
--timeline-smooth 5 \
--prettyGDELT DOC API Constraints and Safety Notes
This skill uses GDELT DOC 2.0 API for query-driven retrieval.
Constraints Confirmed from Official Documentation
- DOC API is query-driven and mode-dependent, not raw table-file export.
- Time windows can be set by relative
timespanor absolute UTC datetime bounds.
DOC API Limits (Reference)
Official DOC API documentation notes:
timespanminimum unit is 15 minutes.- Monitoring mode can scan up to 3 months in one call.
MAXRECORDSapplies toartlistand image collage modes:- default
75 - maximum
250 TIMELINESMOOTHmaximum is30.- Timeline modes have bin limits (documentation references cap behavior around 500 bins in specific modes).
- 429 responses can occur when request cadence is too high; use slower polling and retries.
Skill-Side Safety Measures
The official DOC page does not publish a clear numeric QPS quota. This skill applies client-side protections:
- Configurable timeout (
GDELT_TIMEOUT_SECONDS) - Retries with exponential backoff (
GDELT_MAX_RETRIES,GDELT_RETRY_BACKOFF_*) - Request throttling (
GDELT_MIN_REQUEST_INTERVAL_SECONDS, default5.0) - Parameter validation before request dispatch
- Atomic invocation design: no internal polling loop in this skill
Reference:
https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/
GDELT OpenClaw Chaining Templates (Canonical)
This is the canonical chaining template reference for:
$gdelt-doc-search$gdelt-events-fetch$gdelt-mentions-fetch$gdelt-gkg-fetch
Template A: Topic Recon -> Synchronized Three-Table Pull
Use when you need topical signal discovery first and then deterministic raw table ingestion over one shared UTC window.
Step 1: DOC API recon
Use $gdelt-doc-search.
Run:
python3 scripts/gdelt_doc_search.py search \
--query '[QUERY_EXPRESSION]' \
--mode timelinevolraw \
--format json \
--start-datetime [YYYYMMDDHHMMSS] \
--end-datetime [YYYYMMDDHHMMSS] \
--timeline-smooth 3 \
--output [DOC_TIMELINE_JSON] \
--pretty
Return only JSON.Step 2: Dry-run three raw tables with same window
Use $gdelt-events-fetch, $gdelt-mentions-fetch, $gdelt-gkg-fetch.
Run:
python3 scripts/gdelt_events_fetch.py fetch --mode range --start-datetime [WIN_START] --end-datetime [WIN_END] --max-files [N] --dry-run --pretty
python3 scripts/gdelt_mentions_fetch.py fetch --mode range --start-datetime [WIN_START] --end-datetime [WIN_END] --max-files [N] --dry-run --pretty
python3 scripts/gdelt_gkg_fetch.py fetch --mode range --start-datetime [WIN_START] --end-datetime [WIN_END] --max-files [N] --dry-run --pretty
Return JSON for each.Step 3: Fetch and validate
Use $gdelt-events-fetch, $gdelt-mentions-fetch, $gdelt-gkg-fetch.
Run:
python3 scripts/gdelt_events_fetch.py fetch --mode range --start-datetime [WIN_START] --end-datetime [WIN_END] --max-files [N] --output-dir [EVENTS_DIR] --validate-structure --expected-columns 61 --quarantine-dir [EVENTS_QUAR] --pretty
python3 scripts/gdelt_mentions_fetch.py fetch --mode range --start-datetime [WIN_START] --end-datetime [WIN_END] --max-files [N] --output-dir [MENTIONS_DIR] --validate-structure --expected-columns 16 --quarantine-dir [MENTIONS_QUAR] --pretty
python3 scripts/gdelt_gkg_fetch.py fetch --mode range --start-datetime [WIN_START] --end-datetime [WIN_END] --max-files [N] --output-dir [GKG_DIR] --validate-structure --expected-columns 27 --quarantine-dir [GKG_QUAR] --prettyStep 4: Unified return contract
Return one final summary including:
- time window used (
WIN_START,WIN_END) - per-table
selected_count,downloaded_count,skipped_count - per-table validation issue counts
- output artifact paths
Template B: Direct Three-Table Window Pull (No DOC Recon)
Use when the UTC window is already decided.
Use $gdelt-events-fetch, $gdelt-mentions-fetch, $gdelt-gkg-fetch.
For each table:
1. range dry-run
2. range fetch with table-specific expected-columns
3. return per-table manifest and validation summaryColumn and Schema Expectations
- Events expected columns:
61 - Mentions expected columns:
16 - GKG expected columns:
27
Orchestration Rules
- Keep one shared UTC window across all three table pulls.
- Keep each skill atomic. Do not add scheduler/polling loops inside skills.
- If periodic execution is required, schedule repeated invocations externally in OpenClaw.
#!/usr/bin/env python3
"""Query GDELT DOC 2.0 API with retries, throttling, and rich logs."""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib import parse, request
from urllib.error import HTTPError, URLError
ENV_DOC_API_BASE_URL = "GDELT_DOC_API_BASE_URL"
ENV_TIMEOUT_SECONDS = "GDELT_TIMEOUT_SECONDS"
ENV_MAX_RETRIES = "GDELT_MAX_RETRIES"
ENV_RETRY_BACKOFF_SECONDS = "GDELT_RETRY_BACKOFF_SECONDS"
ENV_RETRY_BACKOFF_MULTIPLIER = "GDELT_RETRY_BACKOFF_MULTIPLIER"
ENV_MIN_REQUEST_INTERVAL_SECONDS = "GDELT_MIN_REQUEST_INTERVAL_SECONDS"
ENV_USER_AGENT = "GDELT_USER_AGENT"
DEFAULT_DOC_API_BASE_URL = "https://api.gdeltproject.org/api/v2/doc/doc"
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_RETRIES = 4
DEFAULT_RETRY_BACKOFF_SECONDS = 1.5
DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0
DEFAULT_MIN_REQUEST_INTERVAL_SECONDS = 5.0
DEFAULT_USER_AGENT = "gdelt-doc-search/1.0"
TS_FORMAT = "%Y%m%d%H%M%S"
TS_FORMAT_HELP = "YYYYMMDDHHMMSS"
RETRIABLE_HTTP_CODES = {429, 500, 502, 503, 504}
DOC_API_MAX_RECORDS_LIMIT = 250
DOC_API_TIMELINE_SMOOTH_LIMIT = 30
RESERVED_DOC_PARAM_KEYS = {
"query",
"mode",
"format",
"timespan",
"startdatetime",
"enddatetime",
"maxrecords",
"sort",
"timelinesmooth",
}
JSON_BODY_EXCERPT_LIMIT = 300
@dataclass(frozen=True)
class RuntimeConfig:
doc_api_base_url: str
timeout_seconds: int
max_retries: int
retry_backoff_seconds: float
retry_backoff_multiplier: float
min_request_interval_seconds: float
user_agent: str
def env_or_default(name: str, default: str) -> str:
value = os.environ.get(name, "").strip()
return value or default
def parse_key_value_args(items: list[str], arg_name: str) -> dict[str, str]:
pairs: dict[str, str] = {}
for item in items:
if "=" not in item:
raise ValueError(f"Invalid {arg_name} entry {item!r}. Use key=value format.")
key, value = item.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
raise ValueError(f"Invalid {arg_name} entry {item!r}. Key cannot be empty.")
pairs[key] = value
return pairs
def parse_positive_int(name: str, raw: str) -> int:
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer, got: {raw!r}") from exc
if value <= 0:
raise ValueError(f"{name} must be > 0, got: {value}")
return value
def parse_non_negative_int(name: str, raw: str) -> int:
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer, got: {raw!r}") from exc
if value < 0:
raise ValueError(f"{name} must be >= 0, got: {value}")
return value
def parse_positive_float(name: str, raw: str) -> float:
try:
value = float(raw)
except ValueError as exc:
raise ValueError(f"{name} must be a number, got: {raw!r}") from exc
if value <= 0:
raise ValueError(f"{name} must be > 0, got: {value}")
return value
def parse_non_negative_float(name: str, raw: str) -> float:
try:
value = float(raw)
except ValueError as exc:
raise ValueError(f"{name} must be a number, got: {raw!r}") from exc
if value < 0:
raise ValueError(f"{name} must be >= 0, got: {value}")
return value
def parse_timestamp(raw: str) -> datetime:
try:
parsed = datetime.strptime(raw, TS_FORMAT)
except ValueError as exc:
raise ValueError(
f"Invalid timestamp {raw!r}. Use UTC format {TS_FORMAT} (YYYYMMDDHHMMSS)."
) from exc
return parsed.replace(tzinfo=timezone.utc)
def normalize_base_url(value: str) -> str:
normalized = value.strip().rstrip("/")
if not normalized:
raise ValueError("Base URL cannot be empty.")
if not normalized.startswith("http://") and not normalized.startswith("https://"):
raise ValueError(f"Base URL must start with http:// or https://, got: {normalized}")
return normalized
def build_runtime_config(args: argparse.Namespace) -> RuntimeConfig:
doc_api_base_url = normalize_base_url(
args.doc_api_base_url
if args.doc_api_base_url
else env_or_default(ENV_DOC_API_BASE_URL, DEFAULT_DOC_API_BASE_URL)
)
timeout_seconds = parse_positive_int(
"--timeout-seconds",
str(
args.timeout_seconds
if args.timeout_seconds is not None
else env_or_default(ENV_TIMEOUT_SECONDS, str(DEFAULT_TIMEOUT_SECONDS))
),
)
max_retries = parse_non_negative_int(
"--max-retries",
str(
args.max_retries
if args.max_retries is not None
else env_or_default(ENV_MAX_RETRIES, str(DEFAULT_MAX_RETRIES))
),
)
retry_backoff_seconds = parse_positive_float(
"--retry-backoff-seconds",
str(
args.retry_backoff_seconds
if args.retry_backoff_seconds is not None
else env_or_default(ENV_RETRY_BACKOFF_SECONDS, str(DEFAULT_RETRY_BACKOFF_SECONDS))
),
)
retry_backoff_multiplier = parse_positive_float(
"--retry-backoff-multiplier",
str(
args.retry_backoff_multiplier
if args.retry_backoff_multiplier is not None
else env_or_default(
ENV_RETRY_BACKOFF_MULTIPLIER, str(DEFAULT_RETRY_BACKOFF_MULTIPLIER)
)
),
)
min_request_interval_seconds = parse_non_negative_float(
"--min-request-interval-seconds",
str(
args.min_request_interval_seconds
if args.min_request_interval_seconds is not None
else env_or_default(
ENV_MIN_REQUEST_INTERVAL_SECONDS, str(DEFAULT_MIN_REQUEST_INTERVAL_SECONDS)
)
),
)
user_agent = (
args.user_agent
if args.user_agent is not None
else env_or_default(ENV_USER_AGENT, DEFAULT_USER_AGENT)
).strip()
if not user_agent:
raise ValueError("User-Agent cannot be empty.")
return RuntimeConfig(
doc_api_base_url=doc_api_base_url,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
retry_backoff_seconds=retry_backoff_seconds,
retry_backoff_multiplier=retry_backoff_multiplier,
min_request_interval_seconds=min_request_interval_seconds,
user_agent=user_agent,
)
def build_logger(level: str, log_file: str) -> logging.Logger:
logger = logging.getLogger("gdelt-doc-search")
logger.handlers.clear()
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
stream_handler = logging.StreamHandler(sys.stderr)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
if log_file.strip():
log_path = Path(log_file).expanduser().resolve()
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.propagate = False
return logger
class RetryableHttpClient:
def __init__(self, config: RuntimeConfig, logger: logging.Logger) -> None:
self._cfg = config
self._logger = logger
self._last_request_monotonic: float | None = None
def _throttle(self) -> None:
if self._last_request_monotonic is None:
return
gap = time.monotonic() - self._last_request_monotonic
sleep_seconds = self._cfg.min_request_interval_seconds - gap
if sleep_seconds > 0:
self._logger.debug("throttle-sleep=%.3fs", sleep_seconds)
time.sleep(sleep_seconds)
@staticmethod
def _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
value = value.strip()
if not value:
return None
try:
parsed = float(value)
except ValueError:
return None
return parsed if parsed >= 0 else None
def get_bytes(self, url: str) -> tuple[bytes, dict[str, str]]:
attempts = self._cfg.max_retries + 1
for attempt in range(1, attempts + 1):
self._throttle()
req = request.Request(url, method="GET")
req.add_header("User-Agent", self._cfg.user_agent)
req.add_header("Accept", "*/*")
self._logger.info("http-get attempt=%d/%d url=%s", attempt, attempts, url)
try:
with request.urlopen(req, timeout=self._cfg.timeout_seconds) as resp:
payload = resp.read()
headers = {k.lower(): v for k, v in resp.headers.items()}
self._last_request_monotonic = time.monotonic()
self._logger.info(
"http-ok status=%s bytes=%d url=%s",
getattr(resp, "status", "unknown"),
len(payload),
url,
)
return payload, headers
except HTTPError as exc:
self._last_request_monotonic = time.monotonic()
status = int(exc.code)
body = exc.read().decode("utf-8", errors="replace").strip()
body_excerpt = body[:300]
retriable = status in RETRIABLE_HTTP_CODES
retry_after = self._parse_retry_after(exc.headers.get("Retry-After"))
if retriable and attempt < attempts:
delay = (
retry_after
if retry_after is not None
else self._cfg.retry_backoff_seconds
* (self._cfg.retry_backoff_multiplier ** (attempt - 1))
)
self._logger.warning(
"http-retry status=%d delay=%.2fs attempt=%d/%d url=%s body=%s",
status,
delay,
attempt,
attempts,
url,
body_excerpt,
)
time.sleep(delay)
continue
raise RuntimeError(
f"HTTP {status} for {url}. body_excerpt={body_excerpt!r}"
) from exc
except (URLError, TimeoutError) as exc:
self._last_request_monotonic = time.monotonic()
if attempt < attempts:
delay = self._cfg.retry_backoff_seconds * (
self._cfg.retry_backoff_multiplier ** (attempt - 1)
)
self._logger.warning(
"network-retry delay=%.2fs attempt=%d/%d url=%s err=%s",
delay,
attempt,
attempts,
url,
exc,
)
time.sleep(delay)
continue
raise RuntimeError(f"Request failed for {url}: {exc}") from exc
raise RuntimeError(f"Failed to fetch after retries: {url}")
def validate_search_args(args: argparse.Namespace) -> None:
if not args.query.strip():
raise ValueError("--query cannot be empty.")
if not args.mode.strip():
raise ValueError("--mode cannot be empty.")
if not args.format.strip():
raise ValueError("--format cannot be empty.")
has_start = bool(args.start_datetime.strip())
has_end = bool(args.end_datetime.strip())
if has_start != has_end:
raise ValueError("--start-datetime and --end-datetime must be provided together.")
if args.timespan.strip() and has_start:
raise ValueError("Use either --timespan or --start-datetime/--end-datetime, not both.")
if has_start:
start = parse_timestamp(args.start_datetime.strip())
end = parse_timestamp(args.end_datetime.strip())
if end < start:
raise ValueError("--end-datetime must be >= --start-datetime.")
if args.max_records is not None:
if args.max_records < 1:
raise ValueError("--max-records must be >= 1.")
if args.max_records > DOC_API_MAX_RECORDS_LIMIT:
raise ValueError(
f"--max-records cannot exceed {DOC_API_MAX_RECORDS_LIMIT} "
"(DOC API max for artlist/imagecollage modes)."
)
if args.timeline_smooth is not None:
if args.timeline_smooth < 0:
raise ValueError("--timeline-smooth must be >= 0.")
if args.timeline_smooth > DOC_API_TIMELINE_SMOOTH_LIMIT:
raise ValueError(
f"--timeline-smooth cannot exceed {DOC_API_TIMELINE_SMOOTH_LIMIT}."
)
def build_search_params(args: argparse.Namespace) -> dict[str, str]:
extra_params = parse_key_value_args(args.param, "--param")
for key in extra_params:
if key.strip().lower() in RESERVED_DOC_PARAM_KEYS:
raise ValueError(f"--param cannot override reserved key: {key!r}")
params: dict[str, str] = {
"query": args.query.strip(),
"mode": args.mode.strip(),
"format": args.format.strip(),
}
if args.timespan.strip():
params["timespan"] = args.timespan.strip()
if args.start_datetime.strip():
params["STARTDATETIME"] = args.start_datetime.strip()
params["ENDDATETIME"] = args.end_datetime.strip()
if args.max_records is not None:
params["MAXRECORDS"] = str(args.max_records)
if args.sort.strip():
params["sort"] = args.sort.strip()
if args.timeline_smooth is not None:
params["TIMELINESMOOTH"] = str(args.timeline_smooth)
params.update(extra_params)
return params
def decode_text(content: bytes) -> str:
return content.decode("utf-8", errors="replace")
def parse_json_response(payload_bytes: bytes) -> Any:
text = decode_text(payload_bytes)
try:
return json.loads(text)
except json.JSONDecodeError as exc:
excerpt = " ".join(text.split())[:JSON_BODY_EXCERPT_LIMIT]
raise RuntimeError(
"DOC API returned non-JSON content while format=json. "
f"body_excerpt={excerpt!r}"
) from exc
def print_json(payload: dict[str, Any], pretty: bool) -> None:
print(
json.dumps(
payload,
ensure_ascii=False,
indent=2 if pretty else None,
separators=None if pretty else (",", ":"),
)
)
def command_check_config(args: argparse.Namespace) -> int:
config = build_runtime_config(args)
payload = {
"ok": True,
"config": {
"doc_api_base_url": config.doc_api_base_url,
"timeout_seconds": config.timeout_seconds,
"max_retries": config.max_retries,
"retry_backoff_seconds": config.retry_backoff_seconds,
"retry_backoff_multiplier": config.retry_backoff_multiplier,
"min_request_interval_seconds": config.min_request_interval_seconds,
"user_agent": config.user_agent,
},
"source_urls": {
"doc_api": config.doc_api_base_url,
},
"env_keys": {
"doc_api_base_url": ENV_DOC_API_BASE_URL,
"timeout_seconds": ENV_TIMEOUT_SECONDS,
"max_retries": ENV_MAX_RETRIES,
"retry_backoff_seconds": ENV_RETRY_BACKOFF_SECONDS,
"retry_backoff_multiplier": ENV_RETRY_BACKOFF_MULTIPLIER,
"min_request_interval_seconds": ENV_MIN_REQUEST_INTERVAL_SECONDS,
"user_agent": ENV_USER_AGENT,
},
}
print_json(payload, pretty=args.pretty)
return 0
def command_search(args: argparse.Namespace) -> int:
logger = build_logger(level=args.log_level, log_file=args.log_file)
config = build_runtime_config(args)
validate_search_args(args)
params = build_search_params(args)
request_url = f"{config.doc_api_base_url}?{parse.urlencode(params)}"
client = RetryableHttpClient(config=config, logger=logger)
payload_bytes, headers = client.get_bytes(request_url)
json_requested = args.format.strip().lower() == "json"
parsed_json: Any | None = None
if json_requested:
parsed_json = parse_json_response(payload_bytes)
output_file = args.output.strip()
if output_file:
output_path = Path(output_file).expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(payload_bytes)
result = {
"ok": True,
"source": "gdelt-doc-api",
"request_url": request_url,
"content_type": headers.get("content-type"),
"bytes_written": len(payload_bytes),
"output_path": str(output_path),
}
print_json(result, pretty=args.pretty)
return 0
if args.format.strip().lower() == "json":
result = {
"ok": True,
"source": "gdelt-doc-api",
"request_url": request_url,
"content_type": headers.get("content-type"),
"data": parsed_json,
}
print_json(result, pretty=args.pretty)
return 0
print(decode_text(payload_bytes))
return 0
def add_runtime_config_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--doc-api-base-url",
default="",
help=f"Override DOC API base URL. Default: {DEFAULT_DOC_API_BASE_URL}",
)
parser.add_argument(
"--timeout-seconds",
type=int,
default=None,
help=f"HTTP timeout in seconds. Env: {ENV_TIMEOUT_SECONDS}.",
)
parser.add_argument(
"--max-retries",
type=int,
default=None,
help=f"Retry count for transient errors. Env: {ENV_MAX_RETRIES}.",
)
parser.add_argument(
"--retry-backoff-seconds",
type=float,
default=None,
help=f"Initial backoff seconds before retries. Env: {ENV_RETRY_BACKOFF_SECONDS}.",
)
parser.add_argument(
"--retry-backoff-multiplier",
type=float,
default=None,
help=f"Backoff multiplier between retry attempts. Env: {ENV_RETRY_BACKOFF_MULTIPLIER}.",
)
parser.add_argument(
"--min-request-interval-seconds",
type=float,
default=None,
help=f"Minimum interval between requests. Env: {ENV_MIN_REQUEST_INTERVAL_SECONDS}.",
)
parser.add_argument(
"--user-agent",
default=None,
help=f"HTTP User-Agent header. Env: {ENV_USER_AGENT}.",
)
def add_logging_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Log verbosity written to stderr/log-file.",
)
parser.add_argument(
"--log-file",
default="",
help="Optional log file path. When set, append logs to this file.",
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Query GDELT DOC 2.0 API with configurable retry, throttling, and logging."
)
sub = parser.add_subparsers(dest="command", required=True)
check = sub.add_parser("check-config", help="Show effective runtime config and source URL.")
add_runtime_config_args(check)
check.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.")
search = sub.add_parser(
"search",
aliases=["doc-search"],
help="Search GDELT DOC API with query syntax.",
)
add_runtime_config_args(search)
add_logging_args(search)
search.add_argument("--query", required=True, help="DOC API query string.")
search.add_argument(
"--mode",
default="artlist",
help="DOC API mode, for example artlist, timelinevol, timelinevolraw, timelinetone.",
)
search.add_argument(
"--format",
default="json",
help="DOC API output format, for example json, csv, html, rss, rssarchive.",
)
search.add_argument(
"--timespan",
default="",
help="Relative search span, for example 1h, 3days, 1week, 3months.",
)
search.add_argument(
"--start-datetime",
default="",
help=f"Absolute UTC start timestamp format {TS_FORMAT_HELP}.",
)
search.add_argument(
"--end-datetime",
default="",
help=f"Absolute UTC end timestamp format {TS_FORMAT_HELP}.",
)
search.add_argument(
"--max-records",
type=int,
default=None,
help=f"DOC API MAXRECORDS (1-{DOC_API_MAX_RECORDS_LIMIT}, mainly artlist/imagecollage modes).",
)
search.add_argument("--sort", default="", help="Optional sort, for example datedesc.")
search.add_argument(
"--timeline-smooth",
type=int,
default=None,
help=f"Optional TIMELINESMOOTH (0-{DOC_API_TIMELINE_SMOOTH_LIMIT}).",
)
search.add_argument(
"--param",
action="append",
default=[],
help="Extra DOC API parameter key=value. Repeat for multiple params.",
)
search.add_argument(
"--output",
default="",
help="Optional output file path. If set, write raw response bytes to this path.",
)
search.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
if args.command == "check-config":
return command_check_config(args)
if args.command in {"search", "doc-search"}:
return command_search(args)
raise ValueError(f"Unknown command: {args.command}")
except KeyboardInterrupt:
print("[ERROR] Interrupted by user.", file=sys.stderr)
return 130
except Exception as exc: # noqa: BLE001
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())