1
- use anyhow:: Result ;
2
- use clap:: { Parser , Subcommand } ;
1
+ use anyhow:: { Result , anyhow } ;
2
+ use clap:: { ArgGroup , 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 wp_api:: {
8
+ comments:: CommentListParams ,
9
+ parsed_url:: ParsedUrl ,
10
+ posts:: { PostId , PostRetrieveParams } ,
11
+ request:: endpoint:: WpOrgSiteApiUrlResolver ,
12
+ wp_com:: { WpComBaseUrl , endpoint:: WpComDotOrgApiUrlResolver } ,
13
+ } ;
7
14
use wp_api:: {
8
15
login:: url_discovery:: {
9
16
AutoDiscoveryAttemptFailure , FetchAndParseApiRootFailure , FindApiRootFailure ,
@@ -29,6 +36,8 @@ enum Commands {
29
36
input_file : String ,
30
37
output_file : String ,
31
38
} ,
39
+ /// Fetch a single post and its comments
40
+ FetchPost ( FetchPostArgs ) ,
32
41
}
33
42
34
43
#[ tokio:: main]
@@ -46,6 +55,9 @@ async fn main() -> Result<()> {
46
55
} => {
47
56
batch_test_autodiscovery ( & login_client, input_file. as_str ( ) , output_file) . await ?;
48
57
}
58
+ Commands :: FetchPost ( args) => {
59
+ fetch_post_and_comments ( args) . await ?;
60
+ }
49
61
}
50
62
51
63
Ok ( ( ) )
@@ -123,6 +135,180 @@ fn build_login_client() -> WpLoginClient {
123
135
WpLoginClient :: new_with_default_middleware_pipeline ( request_executor)
124
136
}
125
137
138
+ #[ derive( Debug , Parser ) ]
139
+ #[ command( group(
140
+ ArgGroup :: new( "target" )
141
+ . required( true )
142
+ . args( [ "wpcom_site" , "api_root" ] ) ,
143
+ ) ) ]
144
+ struct FetchPostArgs {
145
+ /// The post ID to fetch
146
+ #[ arg( long, value_parser = parse_post_id) ]
147
+ post_id : PostId ,
148
+
149
+ /// For WordPress.com: site identifier (e.g. example.wordpress.com or numeric site id)
150
+ #[ arg( long) ]
151
+ wpcom_site : Option < String > ,
152
+
153
+ /// For WordPress.org/Jetpack: full API root URL (must end with /wp-json)
154
+ #[ arg( long) ]
155
+ api_root : Option < String > ,
156
+
157
+ /// Bearer token for WordPress.com (fallback env: WP_BEARER_TOKEN)
158
+ #[ arg( long) ]
159
+ bearer : Option < String > ,
160
+
161
+ /// Application Password username for wp.org/Jetpack (fallback env: WP_USERNAME)
162
+ #[ arg( long) ]
163
+ username : Option < String > ,
164
+
165
+ /// Application Password for wp.org/Jetpack (fallback env: WP_APP_PASSWORD)
166
+ #[ arg( long) ]
167
+ password : Option < String > ,
168
+
169
+ /// Password for the post if it is password-protected
170
+ #[ arg( long) ]
171
+ post_password : Option < String > ,
172
+
173
+ /// Max items per page when fetching comments
174
+ #[ arg( long, default_value_t = 100 ) ]
175
+ per_page : u32 ,
176
+
177
+ /// Output pretty-printed JSON
178
+ #[ arg( long, default_value_t = false ) ]
179
+ pretty : bool ,
180
+ }
181
+
182
+ fn parse_post_id ( s : & str ) -> Result < PostId , String > {
183
+ s. parse :: < i64 > ( )
184
+ . map ( PostId )
185
+ . map_err ( |e| format ! ( "Invalid post id '{s}': {e}" ) )
186
+ }
187
+
188
+ #[ derive( Debug ) ]
189
+ enum TargetSiteResolver {
190
+ WpCom { site : String } ,
191
+ WpOrg { api_root : Arc < ParsedUrl > } ,
192
+ }
193
+
194
+ fn build_api_client ( args : & FetchPostArgs ) -> Result < WpApiClient > {
195
+ // Determine target and auth
196
+ let target = if let Some ( site) = & args. wpcom_site {
197
+ TargetSiteResolver :: WpCom { site : site. clone ( ) }
198
+ } else if let Some ( api_root) = & args. api_root {
199
+ let parsed = ParsedUrl :: try_from ( api_root. as_str ( ) ) . map_err ( |_| {
200
+ anyhow ! ( "Invalid api_root URL: must be a valid URL ending with /wp-json" )
201
+ } ) ?;
202
+ TargetSiteResolver :: WpOrg {
203
+ api_root : Arc :: new ( parsed) ,
204
+ }
205
+ } else {
206
+ return Err ( anyhow ! (
207
+ "Either --wpcom-site or --api-root must be provided"
208
+ ) ) ;
209
+ } ;
210
+
211
+ fn env_or_arg ( value : & Option < String > , var : & str ) -> Option < String > {
212
+ value. clone ( ) . or_else ( || std:: env:: var ( var) . ok ( ) )
213
+ }
214
+
215
+ let ( resolver, auth_provider) : ( Arc < dyn ApiUrlResolver > , Arc < WpAuthenticationProvider > ) =
216
+ match target {
217
+ TargetSiteResolver :: WpCom { site } => {
218
+ let token = env_or_arg ( & args. bearer , "WP_BEARER_TOKEN" ) . ok_or_else ( || {
219
+ anyhow ! ( "Missing bearer token. Provide --bearer or set WP_BEARER_TOKEN" )
220
+ } ) ?;
221
+ let resolver: Arc < dyn ApiUrlResolver > = Arc :: new ( WpComDotOrgApiUrlResolver :: new (
222
+ site,
223
+ WpComBaseUrl :: Production ,
224
+ ) ) ;
225
+ let auth_provider = Arc :: new ( WpAuthenticationProvider :: static_with_auth (
226
+ WpAuthentication :: Bearer { token } ,
227
+ ) ) ;
228
+ ( resolver, auth_provider)
229
+ }
230
+ TargetSiteResolver :: WpOrg { api_root } => {
231
+ let username = env_or_arg ( & args. username , "WP_USERNAME" ) . ok_or_else ( || {
232
+ anyhow ! ( "Missing username. Provide --username or set WP_USERNAME" )
233
+ } ) ?;
234
+ let password = env_or_arg ( & args. password , "WP_APP_PASSWORD" ) . ok_or_else ( || {
235
+ anyhow ! (
236
+ "Missing application password. Provide --password or set WP_APP_PASSWORD"
237
+ )
238
+ } ) ?;
239
+ let resolver: Arc < dyn ApiUrlResolver > =
240
+ Arc :: new ( WpOrgSiteApiUrlResolver :: new ( api_root) ) ;
241
+ let auth_provider = Arc :: new (
242
+ WpAuthenticationProvider :: static_with_username_and_password ( username, password) ,
243
+ ) ;
244
+ ( resolver, auth_provider)
245
+ }
246
+ } ;
247
+
248
+ let request_executor = Arc :: new ( ReqwestRequestExecutor :: new ( false , Duration :: from_secs ( 60 ) ) ) ;
249
+ let middleware_pipeline = Arc :: new ( WpApiMiddlewarePipeline :: default ( ) ) ;
250
+
251
+ #[ derive( Debug ) ]
252
+ struct NoopNotifier ;
253
+ #[ async_trait:: async_trait]
254
+ impl WpAppNotifier for NoopNotifier {
255
+ async fn requested_with_invalid_authentication ( & self ) { }
256
+ }
257
+
258
+ Ok ( WpApiClient :: new (
259
+ resolver,
260
+ WpApiClientDelegate {
261
+ auth_provider,
262
+ request_executor,
263
+ middleware_pipeline,
264
+ app_notifier : Arc :: new ( NoopNotifier ) ,
265
+ } ,
266
+ ) )
267
+ }
268
+
269
+ async fn fetch_post_and_comments ( args : FetchPostArgs ) -> Result < ( ) > {
270
+ let client = build_api_client ( & args) ?;
271
+
272
+ let post = client
273
+ . posts ( )
274
+ . retrieve_with_view_context (
275
+ & args. post_id ,
276
+ & PostRetrieveParams {
277
+ password : args. post_password . clone ( ) ,
278
+ } ,
279
+ )
280
+ . await ?;
281
+
282
+ let mut all_comments = Vec :: new ( ) ;
283
+ let mut page = client
284
+ . comments ( )
285
+ . list_with_view_context ( & CommentListParams {
286
+ post : vec ! [ args. post_id] ,
287
+ per_page : Some ( args. per_page ) ,
288
+ ..Default :: default ( )
289
+ } )
290
+ . await ?;
291
+ all_comments. extend ( page. data ) ;
292
+ while let Some ( next_params) = page. next_page_params . take ( ) {
293
+ page = client
294
+ . comments ( )
295
+ . list_with_view_context ( & next_params)
296
+ . await ?;
297
+ all_comments. extend ( page. data ) ;
298
+ }
299
+
300
+ let out = serde_json:: json!( {
301
+ "post" : post,
302
+ "comments" : all_comments,
303
+ } ) ;
304
+ if args. pretty {
305
+ println ! ( "{}" , serde_json:: to_string_pretty( & out) ?) ;
306
+ } else {
307
+ println ! ( "{}" , serde_json:: to_string( & out) ?) ;
308
+ }
309
+ Ok ( ( ) )
310
+ }
311
+
126
312
fn parse_input_file ( input_file : & str ) -> Result < Vec < BatchTestRow > > {
127
313
Ok ( csv:: Reader :: from_path ( input_file) ?
128
314
. deserialize :: < BatchTestRow > ( )
0 commit comments