gdeltforge 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdeltforge/__init__.py +6 -0
- gdeltforge/_version.py +24 -0
- gdeltforge/cli.py +567 -0
- gdeltforge/conversion/__init__.py +0 -0
- gdeltforge/conversion/converter.py +445 -0
- gdeltforge/crossref/__init__.py +0 -0
- gdeltforge/crossref/crossref.py +219 -0
- gdeltforge/data/cameo_codes.json +1695 -0
- gdeltforge/filtering/__init__.py +0 -0
- gdeltforge/filtering/filter.py +363 -0
- gdeltforge/py.typed +0 -0
- gdeltforge/sampling/__init__.py +0 -0
- gdeltforge/sampling/cameo_codes.py +171 -0
- gdeltforge/sampling/indexer.py +102 -0
- gdeltforge/sampling/rng.py +41 -0
- gdeltforge/sampling/samplers.py +617 -0
- gdeltforge/scraping/__init__.py +0 -0
- gdeltforge/scraping/scraper.py +828 -0
- gdeltforge/utils/__init__.py +0 -0
- gdeltforge/utils/config.py +68 -0
- gdeltforge/utils/io.py +80 -0
- gdeltforge/utils/logging.py +26 -0
- gdeltforge-0.4.0.dist-info/METADATA +558 -0
- gdeltforge-0.4.0.dist-info/RECORD +27 -0
- gdeltforge-0.4.0.dist-info/WHEEL +4 -0
- gdeltforge-0.4.0.dist-info/entry_points.txt +2 -0
- gdeltforge-0.4.0.dist-info/licenses/LICENSE +201 -0
gdeltforge/__init__.py
ADDED
gdeltforge/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
gdeltforge/cli.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from gdeltforge.conversion.converter import run_converter
|
|
10
|
+
from gdeltforge.crossref.crossref import crossref_events_gkg_v1, crossref_events_gkg_v2
|
|
11
|
+
from gdeltforge.filtering.filter import run_filter
|
|
12
|
+
|
|
13
|
+
# Samplers
|
|
14
|
+
from gdeltforge.sampling import cameo_codes
|
|
15
|
+
from gdeltforge.sampling.samplers import (
|
|
16
|
+
DailySampler,
|
|
17
|
+
FilteredSampler,
|
|
18
|
+
IndexedSampler,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Pipeline stages
|
|
22
|
+
from gdeltforge.scraping.scraper import run_scraping_pipeline
|
|
23
|
+
from gdeltforge.utils.config import dataset_path_key, load_config
|
|
24
|
+
from gdeltforge.utils.io import ensure_exists, write_parquet_atomic
|
|
25
|
+
from gdeltforge.utils.logging import get_logger
|
|
26
|
+
|
|
27
|
+
# ======================================================================
|
|
28
|
+
# Utilities
|
|
29
|
+
# ======================================================================
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__, log_to_file=True)
|
|
32
|
+
|
|
33
|
+
# CLI-facing --dataset choices, mapped to the dataset keys used throughout
|
|
34
|
+
# config (columns / columns_numeric / filter.columns_to_check / paths.*
|
|
35
|
+
# via dataset_path_key). GKG's two format generations (pre-2015 "v1" and
|
|
36
|
+
# the current, actively-produced "v2") are different schemas with
|
|
37
|
+
# different files, so they're exposed as distinct choices rather than
|
|
38
|
+
# one ambiguous "gkg".
|
|
39
|
+
_DATASET_CHOICES = ["events", "gkg-v1", "gkg-v1-counts", "gkg-v2", "mentions"]
|
|
40
|
+
_DATASET_CLI_TO_CONFIG = {
|
|
41
|
+
"events": "gdelt_event",
|
|
42
|
+
"gkg-v1": "gdelt_gkg_v1",
|
|
43
|
+
"gkg-v1-counts": "gdelt_gkg_v1_counts",
|
|
44
|
+
"gkg-v2": "gdelt_gkg_v2",
|
|
45
|
+
"mentions": "gdelt_mentions",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# crossref's own choices: a GKG generation to join against, not a dataset
|
|
49
|
+
# to read directly (v2 pulls in Mentions internally as the join bridge).
|
|
50
|
+
_CROSSREF_GKG_CHOICES = ["v1", "v1-counts", "v2"]
|
|
51
|
+
_CROSSREF_GKG_TO_CONFIG = {
|
|
52
|
+
"v1": "gdelt_gkg_v1",
|
|
53
|
+
"v1-counts": "gdelt_gkg_v1_counts",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _historical_folder(config: dict, path_key: str) -> str | None:
|
|
58
|
+
"""Return the historical directory path when partitioning is enabled, else None."""
|
|
59
|
+
part_cfg = config.get("converter", {}).get("partitioning", {})
|
|
60
|
+
if not part_cfg.get("enabled", False):
|
|
61
|
+
return None
|
|
62
|
+
return config["paths"].get(path_key)
|
|
63
|
+
|
|
64
|
+
# ======================================================================
|
|
65
|
+
# Subcommand Runners
|
|
66
|
+
# ======================================================================
|
|
67
|
+
|
|
68
|
+
def _parse_date(value: str, arg_name: str) -> date:
|
|
69
|
+
try:
|
|
70
|
+
return date.fromisoformat(value)
|
|
71
|
+
except ValueError as e:
|
|
72
|
+
raise ValueError(f"Invalid date for {arg_name}: '{value}'. Expected YYYY-MM-DD.") from e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_scrape_cmd(config: dict, args: argparse.Namespace) -> None:
|
|
76
|
+
start_date = _parse_date(args.start_date, "--start-date") if args.start_date else None
|
|
77
|
+
end_date = _parse_date(args.end_date, "--end-date") if args.end_date else None
|
|
78
|
+
|
|
79
|
+
if start_date and end_date and start_date > end_date:
|
|
80
|
+
raise ValueError(f"--start-date ({start_date}) must not be after --end-date ({end_date}).")
|
|
81
|
+
|
|
82
|
+
dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
|
|
83
|
+
logger.info("Starting scraping stage...")
|
|
84
|
+
result = run_scraping_pipeline(
|
|
85
|
+
config, start_date=start_date, end_date=end_date, dataset=dataset
|
|
86
|
+
)
|
|
87
|
+
logger.info("Scraping completed.")
|
|
88
|
+
|
|
89
|
+
failed = result["failed"]
|
|
90
|
+
if failed:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Scraping finished with {len(failed)} failed download(s): {', '.join(failed)}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_convert_cmd(config: dict, args: argparse.Namespace) -> None:
|
|
97
|
+
start_date = _parse_date(args.start_date, "--start-date") if args.start_date else None
|
|
98
|
+
end_date = _parse_date(args.end_date, "--end-date") if args.end_date else None
|
|
99
|
+
|
|
100
|
+
if start_date and end_date and start_date > end_date:
|
|
101
|
+
raise ValueError(f"--start-date ({start_date}) must not be after --end-date ({end_date}).")
|
|
102
|
+
|
|
103
|
+
dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
|
|
104
|
+
logger.info("Starting conversion stage...")
|
|
105
|
+
outputs, failed = run_converter(
|
|
106
|
+
config, dataset=dataset, start_date=start_date, end_date=end_date
|
|
107
|
+
)
|
|
108
|
+
logger.info(f"Created {len(outputs)} parquet files.")
|
|
109
|
+
|
|
110
|
+
if failed:
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
f"Conversion finished with {len(failed)} failed file(s): {', '.join(failed)}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_filter_cmd(config: dict, args: argparse.Namespace) -> None:
|
|
117
|
+
start_date = _parse_date(args.start_date, "--start-date") if args.start_date else None
|
|
118
|
+
end_date = _parse_date(args.end_date, "--end-date") if args.end_date else None
|
|
119
|
+
|
|
120
|
+
if start_date and end_date and start_date > end_date:
|
|
121
|
+
raise ValueError(f"--start-date ({start_date}) must not be after --end-date ({end_date}).")
|
|
122
|
+
|
|
123
|
+
dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
|
|
124
|
+
logger.info("Starting filtering stage...")
|
|
125
|
+
files_processed, files_failed = run_filter(
|
|
126
|
+
config, dataset=dataset, start_date=start_date, end_date=end_date
|
|
127
|
+
)
|
|
128
|
+
logger.info("Filtering completed.")
|
|
129
|
+
|
|
130
|
+
if files_failed:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
f"Filtering finished with {files_failed} failed file(s) out of "
|
|
133
|
+
f"{files_processed + files_failed}."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_sampling_cmd(config: dict, args: argparse.Namespace) -> None:
|
|
138
|
+
dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
|
|
139
|
+
source_key, historical_key = (
|
|
140
|
+
("filtered_data_directory", "filtered_historical_directory")
|
|
141
|
+
if args.source == "filtered"
|
|
142
|
+
else ("parquet_data_directory", "parquet_historical_directory")
|
|
143
|
+
)
|
|
144
|
+
source_key = dataset_path_key(dataset, source_key)
|
|
145
|
+
historical_key = dataset_path_key(dataset, historical_key)
|
|
146
|
+
source_folder = ensure_exists(config["paths"][source_key], source_key)
|
|
147
|
+
|
|
148
|
+
out = Path(args.out)
|
|
149
|
+
|
|
150
|
+
# Create parent folder if it does not exist
|
|
151
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
hist_folder = _historical_folder(config, historical_key)
|
|
154
|
+
columns = set(args.columns) if args.columns else None
|
|
155
|
+
|
|
156
|
+
# -----------------------------
|
|
157
|
+
# Indexed Sampling
|
|
158
|
+
# -----------------------------
|
|
159
|
+
if args.mode == "indexed":
|
|
160
|
+
sampler = IndexedSampler(
|
|
161
|
+
folder_path=str(source_folder),
|
|
162
|
+
historical_folder=hist_folder,
|
|
163
|
+
random_state=args.seed,
|
|
164
|
+
columns=columns,
|
|
165
|
+
)
|
|
166
|
+
df = sampler.get_random_sample(args.n)
|
|
167
|
+
write_parquet_atomic(df, out)
|
|
168
|
+
logger.info(f"Saved indexed sample ({len(df)} rows) -> {out}")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# -----------------------------
|
|
172
|
+
# Daily Sampling
|
|
173
|
+
# -----------------------------
|
|
174
|
+
if args.mode == "daily":
|
|
175
|
+
sampler = DailySampler(
|
|
176
|
+
folder_path=str(source_folder),
|
|
177
|
+
historical_folder=hist_folder,
|
|
178
|
+
random_state=args.seed,
|
|
179
|
+
columns=columns,
|
|
180
|
+
)
|
|
181
|
+
df = sampler.get_daily_samples(samples_per_day=args.per_day)
|
|
182
|
+
write_parquet_atomic(df, out)
|
|
183
|
+
logger.info(f"Saved daily sample ({len(df)} rows) -> {out}")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# -----------------------------
|
|
187
|
+
# Filtered Sampling
|
|
188
|
+
# -----------------------------
|
|
189
|
+
if args.mode == "filtered":
|
|
190
|
+
if args.filter is None:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"--filter is required when mode == 'filtered' "
|
|
193
|
+
"(must be JSON string)"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
filter_dict = json.loads(args.filter)
|
|
198
|
+
except json.JSONDecodeError as e:
|
|
199
|
+
raise ValueError(f"Invalid JSON passed to --filter: {e}") from e
|
|
200
|
+
|
|
201
|
+
sampler = FilteredSampler(
|
|
202
|
+
folder_path=str(source_folder),
|
|
203
|
+
gdelt_columns=config["columns"][dataset],
|
|
204
|
+
columns=columns,
|
|
205
|
+
filter_dict=filter_dict,
|
|
206
|
+
random_state=args.seed,
|
|
207
|
+
historical_folder=hist_folder,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if args.stratify:
|
|
211
|
+
if args.n_per_group is None:
|
|
212
|
+
raise ValueError("--n-per-group is required when --stratify is set")
|
|
213
|
+
df = sampler.get_stratified_sample(args.stratify, args.n_per_group)
|
|
214
|
+
write_parquet_atomic(df, out)
|
|
215
|
+
logger.info(
|
|
216
|
+
f"Saved stratified sample ({len(df)} rows) "
|
|
217
|
+
f"stratified by '{args.stratify}' ({args.n_per_group} per group) -> {out}"
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
df = sampler.get_random_sample(args.n)
|
|
221
|
+
write_parquet_atomic(df, out)
|
|
222
|
+
logger.info(
|
|
223
|
+
f"Saved filtered sample ({len(df)} rows) "
|
|
224
|
+
f"using filter={filter_dict} -> {out}"
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
raise ValueError(f"Unknown sampling mode: {args.mode}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def run_crossref_cmd(config: dict, args: argparse.Namespace) -> None:
|
|
232
|
+
events_df = pd.read_parquet(args.events)
|
|
233
|
+
columns = set(args.columns) if args.columns else None
|
|
234
|
+
source_key = (
|
|
235
|
+
"filtered_data_directory" if args.source == "filtered" else "parquet_data_directory"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
out = Path(args.out)
|
|
239
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
|
|
241
|
+
if args.gkg_version == "v2":
|
|
242
|
+
mentions_folder = ensure_exists(
|
|
243
|
+
config["paths"][dataset_path_key("gdelt_mentions", source_key)],
|
|
244
|
+
"Mentions directory",
|
|
245
|
+
)
|
|
246
|
+
gkg_v2_folder = ensure_exists(
|
|
247
|
+
config["paths"][dataset_path_key("gdelt_gkg_v2", source_key)],
|
|
248
|
+
"GKG 2.1 directory",
|
|
249
|
+
)
|
|
250
|
+
result = crossref_events_gkg_v2(
|
|
251
|
+
events_df,
|
|
252
|
+
str(mentions_folder),
|
|
253
|
+
str(gkg_v2_folder),
|
|
254
|
+
config["columns"]["gdelt_gkg_v2"],
|
|
255
|
+
columns=columns,
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
dataset = _CROSSREF_GKG_TO_CONFIG[args.gkg_version]
|
|
259
|
+
gkg_folder = ensure_exists(
|
|
260
|
+
config["paths"][dataset_path_key(dataset, source_key)],
|
|
261
|
+
f"{args.gkg_version} directory",
|
|
262
|
+
)
|
|
263
|
+
result = crossref_events_gkg_v1(
|
|
264
|
+
events_df, str(gkg_folder), config["columns"][dataset], columns=columns,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
write_parquet_atomic(result, out)
|
|
268
|
+
logger.info(f"Saved cross-referenced sample ({len(result)} rows) -> {out}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_CAMEO_COLUMN_GROUPS = [
|
|
272
|
+
cameo_codes.CAMEO_ACTOR_COUNTRY_COLUMNS,
|
|
273
|
+
cameo_codes.FIPS_GEO_COLUMNS,
|
|
274
|
+
cameo_codes.CAMEO_ETHNIC_COLUMNS,
|
|
275
|
+
cameo_codes.CAMEO_KNOWN_GROUP_COLUMNS,
|
|
276
|
+
cameo_codes.CAMEO_RELIGION_COLUMNS,
|
|
277
|
+
cameo_codes.CAMEO_TYPE_COLUMNS,
|
|
278
|
+
cameo_codes.CAMEO_EVENT_COLUMNS,
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def run_codes_cmd(args: argparse.Namespace) -> None:
|
|
283
|
+
if args.column is None:
|
|
284
|
+
print("CAMEO-coded columns with a reference list:\n")
|
|
285
|
+
for group in _CAMEO_COLUMN_GROUPS:
|
|
286
|
+
columns = sorted(group)
|
|
287
|
+
print(f" {cameo_codes.family_name_for_column(columns[0])}:")
|
|
288
|
+
for c in columns:
|
|
289
|
+
print(f" {c}")
|
|
290
|
+
print()
|
|
291
|
+
print("Run `gdeltforge codes <column>` to list that column's codes.")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
code_family = cameo_codes.code_family_for_column(args.column)
|
|
295
|
+
if code_family is None:
|
|
296
|
+
known = sorted(c for group in _CAMEO_COLUMN_GROUPS for c in group)
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"'{args.column}' has no CAMEO code reference list. Known columns: "
|
|
299
|
+
f"{', '.join(known)}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
entries = sorted(code_family.items())
|
|
303
|
+
if args.search:
|
|
304
|
+
term = args.search.lower()
|
|
305
|
+
entries = [
|
|
306
|
+
(code, name) for code, name in entries
|
|
307
|
+
if term in code.lower() or term in name.lower()
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
if not entries:
|
|
311
|
+
print(f"No codes matching '{args.search}' in {args.column}.")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
width = max(len(code) for code, _ in entries)
|
|
315
|
+
for code, name in entries:
|
|
316
|
+
print(f" {code.ljust(width)} {name}")
|
|
317
|
+
print(f"\n{len(entries)} code(s).")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ======================================================================
|
|
321
|
+
# Argument Parser
|
|
322
|
+
# ======================================================================
|
|
323
|
+
|
|
324
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
325
|
+
parser = argparse.ArgumentParser(
|
|
326
|
+
description="GdeltForge: a data pipeline for the GDELT 2.0 Events Database"
|
|
327
|
+
)
|
|
328
|
+
parser.add_argument(
|
|
329
|
+
"--config",
|
|
330
|
+
metavar="PATH",
|
|
331
|
+
help="Path to settings.yaml. Defaults to the GDELTFORGE_CONFIG "
|
|
332
|
+
"environment variable, then ./config/settings.yaml.",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
336
|
+
|
|
337
|
+
# ----------------------------------------------------
|
|
338
|
+
# scrape
|
|
339
|
+
# ----------------------------------------------------
|
|
340
|
+
scrape = subparsers.add_parser("scrape", help="Download and extract raw GDELT data")
|
|
341
|
+
scrape.add_argument(
|
|
342
|
+
"--dataset",
|
|
343
|
+
choices=_DATASET_CHOICES,
|
|
344
|
+
default="events",
|
|
345
|
+
help="Which GDELT dataset to scrape (default: events)"
|
|
346
|
+
)
|
|
347
|
+
scrape.add_argument(
|
|
348
|
+
"--start-date",
|
|
349
|
+
metavar="YYYY-MM-DD",
|
|
350
|
+
help="Only download files whose period starts on or after this date",
|
|
351
|
+
)
|
|
352
|
+
scrape.add_argument(
|
|
353
|
+
"--end-date",
|
|
354
|
+
metavar="YYYY-MM-DD",
|
|
355
|
+
help="Only download files whose period ends on or before this date",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# ----------------------------------------------------
|
|
359
|
+
# convert
|
|
360
|
+
# ----------------------------------------------------
|
|
361
|
+
convert = subparsers.add_parser("convert", help="Convert raw data to parquet")
|
|
362
|
+
convert.add_argument(
|
|
363
|
+
"--dataset",
|
|
364
|
+
choices=_DATASET_CHOICES,
|
|
365
|
+
default="events",
|
|
366
|
+
help="Which GDELT dataset to convert (default: events)"
|
|
367
|
+
)
|
|
368
|
+
convert.add_argument(
|
|
369
|
+
"--start-date",
|
|
370
|
+
metavar="YYYY-MM-DD",
|
|
371
|
+
help="Only convert files whose period starts on or after this date",
|
|
372
|
+
)
|
|
373
|
+
convert.add_argument(
|
|
374
|
+
"--end-date",
|
|
375
|
+
metavar="YYYY-MM-DD",
|
|
376
|
+
help="Only convert files whose period ends on or before this date",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# ----------------------------------------------------
|
|
380
|
+
# filter
|
|
381
|
+
# ----------------------------------------------------
|
|
382
|
+
filter_ = subparsers.add_parser("filter", help="Filter parquet files")
|
|
383
|
+
filter_.add_argument(
|
|
384
|
+
"--dataset",
|
|
385
|
+
choices=_DATASET_CHOICES,
|
|
386
|
+
default="events",
|
|
387
|
+
help="Which GDELT dataset to filter (default: events)"
|
|
388
|
+
)
|
|
389
|
+
filter_.add_argument(
|
|
390
|
+
"--start-date",
|
|
391
|
+
metavar="YYYY-MM-DD",
|
|
392
|
+
help="Only filter files whose period starts on or after this date",
|
|
393
|
+
)
|
|
394
|
+
filter_.add_argument(
|
|
395
|
+
"--end-date",
|
|
396
|
+
metavar="YYYY-MM-DD",
|
|
397
|
+
help="Only filter files whose period ends on or before this date",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# ----------------------------------------------------
|
|
401
|
+
# sample
|
|
402
|
+
# ----------------------------------------------------
|
|
403
|
+
sample = subparsers.add_parser(
|
|
404
|
+
"sample", help="Sampling utilities (indexed, filtered, daily)"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
sample.add_argument(
|
|
408
|
+
"--dataset",
|
|
409
|
+
choices=_DATASET_CHOICES,
|
|
410
|
+
default="events",
|
|
411
|
+
help="Which GDELT dataset to sample from (default: events)"
|
|
412
|
+
)
|
|
413
|
+
sample.add_argument(
|
|
414
|
+
"--mode",
|
|
415
|
+
required=True,
|
|
416
|
+
choices=["indexed", "filtered", "daily"],
|
|
417
|
+
help="Sampling strategy"
|
|
418
|
+
)
|
|
419
|
+
sample.add_argument(
|
|
420
|
+
"--source",
|
|
421
|
+
choices=["filtered", "converted"],
|
|
422
|
+
default="filtered",
|
|
423
|
+
help="Which stage's output to sample from: 'filtered' (default, "
|
|
424
|
+
"after the filter command) or 'converted' (raw parquet, "
|
|
425
|
+
"before filtering)"
|
|
426
|
+
)
|
|
427
|
+
sample.add_argument(
|
|
428
|
+
"-n", type=int, default=1000,
|
|
429
|
+
help="Number of rows to sample"
|
|
430
|
+
)
|
|
431
|
+
sample.add_argument(
|
|
432
|
+
"--seed", type=int, default=42,
|
|
433
|
+
help="RNG seed"
|
|
434
|
+
)
|
|
435
|
+
sample.add_argument(
|
|
436
|
+
"--per-day", type=int, default=10,
|
|
437
|
+
help="Rows per day (daily mode only)"
|
|
438
|
+
)
|
|
439
|
+
sample.add_argument(
|
|
440
|
+
"--filter",
|
|
441
|
+
help="JSON dictionary for filtered sampling "
|
|
442
|
+
"(e.g. '{\"QuadClass\": [1,2]}')"
|
|
443
|
+
)
|
|
444
|
+
sample.add_argument(
|
|
445
|
+
"--columns",
|
|
446
|
+
nargs="*",
|
|
447
|
+
help="Restrict output to these columns (all modes); cuts both I/O "
|
|
448
|
+
"and memory use on the full archive"
|
|
449
|
+
)
|
|
450
|
+
sample.add_argument(
|
|
451
|
+
"--stratify",
|
|
452
|
+
metavar="COLUMN",
|
|
453
|
+
help="Column to stratify by (filtered mode only); requires --n-per-group"
|
|
454
|
+
)
|
|
455
|
+
sample.add_argument(
|
|
456
|
+
"--n-per-group",
|
|
457
|
+
type=int,
|
|
458
|
+
metavar="N",
|
|
459
|
+
help="Rows per stratum when --stratify is set"
|
|
460
|
+
)
|
|
461
|
+
sample.add_argument(
|
|
462
|
+
"--out",
|
|
463
|
+
default="sample.parquet",
|
|
464
|
+
help="Output parquet file"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# ----------------------------------------------------
|
|
468
|
+
# crossref
|
|
469
|
+
# ----------------------------------------------------
|
|
470
|
+
crossref = subparsers.add_parser(
|
|
471
|
+
"crossref", help="Cross-reference a sampled Events output against GKG"
|
|
472
|
+
)
|
|
473
|
+
crossref.add_argument(
|
|
474
|
+
"--events",
|
|
475
|
+
required=True,
|
|
476
|
+
metavar="PATH",
|
|
477
|
+
help="Parquet file of Events rows to enrich, e.g. the output of `gdeltforge sample`"
|
|
478
|
+
)
|
|
479
|
+
crossref.add_argument(
|
|
480
|
+
"--gkg-version",
|
|
481
|
+
required=True,
|
|
482
|
+
choices=_CROSSREF_GKG_CHOICES,
|
|
483
|
+
help="Which GKG generation to join against: v1 (direct join on EventIds, main GKG "
|
|
484
|
+
"1.0 file), v1-counts (same join, GKG 1.0's separate Counts file), or v2 "
|
|
485
|
+
"(two-hop join through Mentions, GKG 2.1)"
|
|
486
|
+
)
|
|
487
|
+
crossref.add_argument(
|
|
488
|
+
"--source",
|
|
489
|
+
choices=["filtered", "converted"],
|
|
490
|
+
default="filtered",
|
|
491
|
+
help="Which stage's GKG/Mentions output to read from (default: filtered)"
|
|
492
|
+
)
|
|
493
|
+
crossref.add_argument(
|
|
494
|
+
"--columns",
|
|
495
|
+
nargs="*",
|
|
496
|
+
help="Restrict GKG-side output to these columns; cuts I/O and memory. The join key "
|
|
497
|
+
"column is always included regardless"
|
|
498
|
+
)
|
|
499
|
+
crossref.add_argument(
|
|
500
|
+
"--out",
|
|
501
|
+
default="crossref.parquet",
|
|
502
|
+
help="Output parquet file"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# ----------------------------------------------------
|
|
506
|
+
# codes
|
|
507
|
+
# ----------------------------------------------------
|
|
508
|
+
codes = subparsers.add_parser(
|
|
509
|
+
"codes", help="Look up valid GDELT country codes for filter values"
|
|
510
|
+
)
|
|
511
|
+
codes.add_argument(
|
|
512
|
+
"column",
|
|
513
|
+
nargs="?",
|
|
514
|
+
help="A country-code column (e.g. ActionGeo_CountryCode). "
|
|
515
|
+
"Omit to list which columns have a reference list.",
|
|
516
|
+
)
|
|
517
|
+
codes.add_argument(
|
|
518
|
+
"--search",
|
|
519
|
+
metavar="TERM",
|
|
520
|
+
help="Filter to codes/names containing this substring (case-insensitive)",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
return parser
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ======================================================================
|
|
527
|
+
# Entrypoint
|
|
528
|
+
# ======================================================================
|
|
529
|
+
|
|
530
|
+
def main() -> None:
|
|
531
|
+
parser = build_parser()
|
|
532
|
+
args = parser.parse_args()
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
if args.command == "codes":
|
|
536
|
+
run_codes_cmd(args)
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
config = load_config(args.config)
|
|
540
|
+
|
|
541
|
+
logger.info(f"Running command: {args.command}")
|
|
542
|
+
|
|
543
|
+
if args.command == "scrape":
|
|
544
|
+
run_scrape_cmd(config, args)
|
|
545
|
+
|
|
546
|
+
elif args.command == "convert":
|
|
547
|
+
run_convert_cmd(config, args)
|
|
548
|
+
|
|
549
|
+
elif args.command == "filter":
|
|
550
|
+
run_filter_cmd(config, args)
|
|
551
|
+
|
|
552
|
+
elif args.command == "sample":
|
|
553
|
+
run_sampling_cmd(config, args)
|
|
554
|
+
|
|
555
|
+
elif args.command == "crossref":
|
|
556
|
+
run_crossref_cmd(config, args)
|
|
557
|
+
|
|
558
|
+
except KeyboardInterrupt:
|
|
559
|
+
print("Interrupted.", file=sys.stderr)
|
|
560
|
+
sys.exit(130)
|
|
561
|
+
except Exception as e:
|
|
562
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
563
|
+
sys.exit(1)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
if __name__ == "__main__":
|
|
567
|
+
main()
|
|
File without changes
|