1
- use anyhow:: Result ;
2
- use clap:: { Parser , Subcommand } ;
1
+ use anyhow:: { Result , anyhow } ;
2
+ use clap:: { ArgGroup , Args , Parser , Subcommand } ;
3
3
use colored:: Colorize ;
4
4
use csv:: Writer ;
5
5
use futures:: stream:: StreamExt ;
6
6
use std:: { fmt:: Display , fs:: File , sync:: Arc , time:: Duration } ;
7
+ use url:: Url ;
8
+ use wp_api:: {
9
+ comments:: CommentListParams ,
10
+ parsed_url:: ParsedUrl ,
11
+ posts:: { PostId , PostRetrieveParams } ,
12
+ request:: endpoint:: WpOrgSiteApiUrlResolver ,
13
+ wp_com:: { WpComBaseUrl , endpoint:: WpComDotOrgApiUrlResolver } ,
14
+ } ;
7
15
use wp_api:: {
8
16
login:: url_discovery:: {
9
17
AutoDiscoveryAttemptFailure , FetchAndParseApiRootFailure , FindApiRootFailure ,
@@ -29,6 +37,67 @@ enum Commands {
29
37
input_file : String ,
30
38
output_file : String ,
31
39
} ,
40
+ /// Fetch a single post and its comments
41
+ FetchPost ( FetchPostArgs ) ,
42
+ }
43
+
44
+ #[ derive( Debug , Args , Clone ) ]
45
+ struct AuthArgs {
46
+ /// WordPress.com site (e.g. example.wordpress.com or numeric ID)
47
+ #[ arg( long) ]
48
+ wpcom_site : Option < String > ,
49
+
50
+ /// WordPress.org/Jetpack API root (must end with /wp-json)
51
+ #[ arg( long) ]
52
+ api_root : Option < String > ,
53
+
54
+ /// Bearer token for WordPress.com (fallback env: WP_BEARER_TOKEN)
55
+ #[ arg( long) ]
56
+ bearer : Option < String > ,
57
+
58
+ /// Application Password username for wp.org/Jetpack (fallback env: WP_USERNAME)
59
+ #[ arg( long) ]
60
+ username : Option < String > ,
61
+
62
+ /// Application Password for wp.org/Jetpack (fallback env: WP_APP_PASSWORD)
63
+ #[ arg( long) ]
64
+ password : Option < String > ,
65
+ }
66
+
67
+ #[ derive( Debug , Parser ) ]
68
+ #[ command( group(
69
+ ArgGroup :: new( "site_type" )
70
+ . args( [ "wpcom_site" , "api_root" , "url" ] ) ,
71
+ ) , group(
72
+ ArgGroup :: new( "post_ref" )
73
+ . required( true )
74
+ . args( [ "post_id" , "url" ] ) ,
75
+ ) ) ]
76
+ struct FetchPostArgs {
77
+ /// Common authentication parameters
78
+ #[ command( flatten) ]
79
+ auth : AuthArgs ,
80
+
81
+ /// Full post URL (alternative to --post-id)
82
+ /// When provided, this URL is used to infer the site (wp.com) or autodiscover the API root (wp.org/Jetpack).
83
+ #[ arg( long) ]
84
+ url : Option < String > ,
85
+
86
+ /// The post ID to fetch
87
+ #[ arg( long, value_parser = parse_post_id) ]
88
+ post_id : Option < PostId > ,
89
+
90
+ /// Password for the post if it is password-protected
91
+ #[ arg( long) ]
92
+ post_password : Option < String > ,
93
+
94
+ /// Max items per page when fetching comments
95
+ #[ arg( long, default_value_t = 100 ) ]
96
+ per_page : u32 ,
97
+
98
+ /// Output pretty-printed JSON
99
+ #[ arg( long, default_value_t = false ) ]
100
+ pretty : bool ,
32
101
}
33
102
34
103
#[ tokio:: main]
@@ -46,6 +115,9 @@ async fn main() -> Result<()> {
46
115
} => {
47
116
batch_test_autodiscovery ( & login_client, input_file. as_str ( ) , output_file) . await ?;
48
117
}
118
+ Commands :: FetchPost ( args) => {
119
+ fetch_post_and_comments ( args) . await ?;
120
+ }
49
121
}
50
122
51
123
Ok ( ( ) )
@@ -221,3 +293,232 @@ fn csv_error_type(failure: &AutoDiscoveryAttemptFailure) -> String {
221
293
} ,
222
294
}
223
295
}
296
+
297
+ #[ derive( Debug ) ]
298
+ enum SiteApiType {
299
+ WpCom { site : String } ,
300
+ WpOrg { api_root : Arc < ParsedUrl > } ,
301
+ }
302
+
303
+ impl SiteApiType {
304
+ async fn detect_from_args (
305
+ args : & AuthArgs ,
306
+ url : & Option < String > ,
307
+ request_executor : & Arc < ReqwestRequestExecutor > ,
308
+ ) -> Result < Self > {
309
+ if let Some ( site) = & args. wpcom_site {
310
+ // Explicit WordPress.com site takes priority
311
+ return Ok ( SiteApiType :: WpCom { site : site. clone ( ) } ) ;
312
+ }
313
+ if let Some ( api_root) = & args. api_root {
314
+ // Explicit api_root takes priority for wp.org/Jetpack
315
+ let parsed = ParsedUrl :: try_from ( api_root. as_str ( ) ) . map_err ( |_| {
316
+ anyhow ! ( "Invalid api_root URL: must be a valid URL ending with /wp-json" )
317
+ } ) ?;
318
+ return Ok ( SiteApiType :: WpOrg {
319
+ api_root : Arc :: new ( parsed) ,
320
+ } ) ;
321
+ }
322
+ if let Some ( url) = url {
323
+ // Derive from URL if possible
324
+ if let Ok ( u) = Url :: parse ( url. as_str ( ) ) {
325
+ let host = u. host_str ( ) . unwrap_or ( "" ) ;
326
+ if host. ends_with ( ".wordpress.com" ) {
327
+ return Ok ( SiteApiType :: WpCom {
328
+ site : host. to_string ( ) ,
329
+ } ) ;
330
+ }
331
+
332
+ // Attempt autodiscovery of API root from URL
333
+ let login_client =
334
+ WpLoginClient :: new_with_default_middleware_pipeline ( request_executor. clone ( ) ) ;
335
+ match login_client
336
+ . api_discovery ( url. clone ( ) )
337
+ . await
338
+ . combined_result ( )
339
+ . cloned ( )
340
+ {
341
+ Ok ( success) => Ok ( SiteApiType :: WpOrg {
342
+ api_root : success. api_root_url ,
343
+ } ) ,
344
+ Err ( _) => Err ( anyhow ! (
345
+ "Could not autodiscover API root from URL. Please provide --api-root explicitly."
346
+ ) ) ,
347
+ }
348
+ } else {
349
+ Err ( anyhow ! ( "Invalid URL; could not parse" ) )
350
+ }
351
+ } else {
352
+ Err ( anyhow ! (
353
+ "Provide either --wpcom-site, or --api-root, or a wordpress.com URL"
354
+ ) )
355
+ }
356
+ }
357
+
358
+ fn api_url_resolver ( & self ) -> Arc < dyn ApiUrlResolver > {
359
+ match self {
360
+ SiteApiType :: WpCom { site } => Arc :: new ( WpComDotOrgApiUrlResolver :: new (
361
+ site. clone ( ) ,
362
+ WpComBaseUrl :: Production ,
363
+ ) ) ,
364
+ SiteApiType :: WpOrg { api_root } => {
365
+ Arc :: new ( WpOrgSiteApiUrlResolver :: new ( api_root. clone ( ) ) )
366
+ }
367
+ }
368
+ }
369
+
370
+ fn auth_provider ( & self , args : & AuthArgs ) -> Result < Arc < WpAuthenticationProvider > > {
371
+ match self {
372
+ SiteApiType :: WpCom { .. } => {
373
+ let token = env_or_arg ( & args. bearer , "WP_BEARER_TOKEN" ) . ok_or_else ( || {
374
+ anyhow ! ( "Missing bearer token. Provide --bearer or set WP_BEARER_TOKEN" )
375
+ } ) ?;
376
+ Ok ( Arc :: new ( WpAuthenticationProvider :: static_with_auth (
377
+ WpAuthentication :: Bearer { token } ,
378
+ ) ) )
379
+ }
380
+ SiteApiType :: WpOrg { .. } => {
381
+ let username = env_or_arg ( & args. username , "WP_USERNAME" ) . ok_or_else ( || {
382
+ anyhow ! ( "Missing username. Provide --username or set WP_USERNAME" )
383
+ } ) ?;
384
+ let password = env_or_arg ( & args. password , "WP_APP_PASSWORD" ) . ok_or_else ( || {
385
+ anyhow ! (
386
+ "Missing application password. Provide --password or set WP_APP_PASSWORD"
387
+ )
388
+ } ) ?;
389
+ Ok ( Arc :: new (
390
+ WpAuthenticationProvider :: static_with_username_and_password ( username, password) ,
391
+ ) )
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ fn env_or_arg ( value : & Option < String > , var : & str ) -> Option < String > {
398
+ value. clone ( ) . or_else ( || std:: env:: var ( var) . ok ( ) )
399
+ }
400
+
401
+ async fn build_api_client ( args : & AuthArgs , url : & Option < String > ) -> Result < WpApiClient > {
402
+ let request_executor = Arc :: new ( ReqwestRequestExecutor :: new ( false , Duration :: from_secs ( 60 ) ) ) ;
403
+ let middleware_pipeline = Arc :: new ( WpApiMiddlewarePipeline :: default ( ) ) ;
404
+ // Determine resolver and auth provider
405
+ let api_type = SiteApiType :: detect_from_args ( args, url, & request_executor) . await ?;
406
+ let resolver: Arc < dyn ApiUrlResolver > = api_type. api_url_resolver ( ) ;
407
+ let auth_provider: Arc < WpAuthenticationProvider > = api_type. auth_provider ( args) ?;
408
+
409
+ #[ derive( Debug ) ]
410
+ struct CliAppNotifier ;
411
+ #[ async_trait:: async_trait]
412
+ impl WpAppNotifier for CliAppNotifier {
413
+ async fn requested_with_invalid_authentication ( & self ) {
414
+ eprintln ! (
415
+ "Authentication failed. Please verify your credentials or token and try again."
416
+ ) ;
417
+ std:: process:: exit ( 1 ) ;
418
+ }
419
+ }
420
+
421
+ Ok ( WpApiClient :: new (
422
+ resolver,
423
+ WpApiClientDelegate {
424
+ auth_provider,
425
+ request_executor,
426
+ middleware_pipeline,
427
+ app_notifier : Arc :: new ( CliAppNotifier ) ,
428
+ } ,
429
+ ) )
430
+ }
431
+
432
+ fn parse_post_id ( s : & str ) -> Result < PostId , String > {
433
+ s. parse :: < i64 > ( )
434
+ . map ( PostId )
435
+ . map_err ( |e| format ! ( "Invalid post id '{s}': {e}" ) )
436
+ }
437
+
438
+ async fn resolve_post_id ( client : & WpApiClient , post_url : & str ) -> Result < PostId > {
439
+ // Strategy: retrieve by slug via posts list API when possible.
440
+ // For wp.com, the resolver requires site context; for wp.org, api_root is given.
441
+ // We'll try to parse the URL and extract a last path segment as potential slug.
442
+ let url = Url :: parse ( post_url) . map_err ( |e| anyhow ! ( "Invalid url: {e}" ) ) ?;
443
+ let slug_candidate = url
444
+ . path_segments ( )
445
+ . and_then ( |segs| segs. rev ( ) . find ( |s| !s. is_empty ( ) ) )
446
+ . map ( |s| s. trim_end_matches ( '/' ) )
447
+ . unwrap_or ( "" )
448
+ . to_string ( ) ;
449
+
450
+ if slug_candidate. is_empty ( ) {
451
+ return Err ( anyhow ! ( "Could not parse a slug from url" ) ) ;
452
+ }
453
+
454
+ // Query posts by slug; returns an array, take first match.
455
+ // Using view context to ensure public content shape.
456
+ let params = wp_api:: posts:: PostListParams {
457
+ slug : vec ! [ slug_candidate. clone( ) ] ,
458
+ per_page : Some ( 1 ) ,
459
+ ..Default :: default ( )
460
+ } ;
461
+ let resp = client. posts ( ) . list_with_view_context ( & params) . await ?;
462
+ if let Some ( p) = resp. data . into_iter ( ) . map ( |sp| sp. id ) . next ( ) {
463
+ return Ok ( p) ;
464
+ }
465
+
466
+ Err ( anyhow ! (
467
+ "No post found for slug '{slug}' parsed from URL '{url}'" ,
468
+ slug = slug_candidate,
469
+ url = post_url
470
+ ) )
471
+ }
472
+
473
+ async fn fetch_post_and_comments ( args : FetchPostArgs ) -> Result < ( ) > {
474
+ let client = build_api_client ( & args. auth , & args. url ) . await ?;
475
+
476
+ let post_id = if let Some ( id) = args. post_id {
477
+ id
478
+ } else {
479
+ let post_url = args
480
+ . url
481
+ . as_ref ( )
482
+ . ok_or_else ( || anyhow ! ( "Either --post-id or --url must be provided" ) ) ?;
483
+ resolve_post_id ( & client, post_url. as_str ( ) ) . await ?
484
+ } ;
485
+
486
+ let post = client
487
+ . posts ( )
488
+ . retrieve_with_view_context (
489
+ & post_id,
490
+ & PostRetrieveParams {
491
+ password : args. post_password . clone ( ) ,
492
+ } ,
493
+ )
494
+ . await ?;
495
+
496
+ let mut all_comments = Vec :: new ( ) ;
497
+ let mut page = client
498
+ . comments ( )
499
+ . list_with_view_context ( & CommentListParams {
500
+ post : vec ! [ post_id] ,
501
+ per_page : Some ( args. per_page ) ,
502
+ ..Default :: default ( )
503
+ } )
504
+ . await ?;
505
+ all_comments. extend ( page. data ) ;
506
+ while let Some ( next_params) = page. next_page_params . take ( ) {
507
+ page = client
508
+ . comments ( )
509
+ . list_with_view_context ( & next_params)
510
+ . await ?;
511
+ all_comments. extend ( page. data ) ;
512
+ }
513
+
514
+ let out = serde_json:: json!( {
515
+ "post" : post,
516
+ "comments" : all_comments,
517
+ } ) ;
518
+ if args. pretty {
519
+ println ! ( "{}" , serde_json:: to_string_pretty( & out) ?) ;
520
+ } else {
521
+ println ! ( "{}" , serde_json:: to_string( & out) ?) ;
522
+ }
523
+ Ok ( ( ) )
524
+ }
0 commit comments