1+ import GhostAdminAPI from '@tryghost/admin-api' ;
2+ import { makeTaskRunner } from '@tryghost/listr-smart-renderer' ;
3+ import _ from 'lodash' ;
4+ import { discover } from '../lib/batch-ghost-discover.js' ;
5+
6+ const initialise = ( options ) => {
7+ return {
8+ title : 'Initialising API connection' ,
9+ task : ( ctx , task ) => {
10+ let defaults = {
11+ verbose : false ,
12+ delayBetweenCalls : 50
13+ } ;
14+
15+ const url = options . apiURL . replace ( / \/ $ / , '' ) ;
16+ const key = options . adminAPIKey ;
17+ const api = new GhostAdminAPI ( {
18+ url : url . replace ( 'localhost' , '127.0.0.1' ) ,
19+ key,
20+ version : 'v5.0'
21+ } ) ;
22+
23+ ctx . args = _ . mergeWith ( defaults , options ) ;
24+ ctx . api = api ;
25+ ctx . processed = 0 ;
26+ ctx . updated = 0 ;
27+ ctx . errors = [ ] ;
28+
29+ task . output = 'API connection initialised' ;
30+ }
31+ } ;
32+ } ;
33+
34+ /**
35+ * Extracts the first image URL from HTML content
36+ * @param {string } html - The HTML content to search in
37+ * @returns {string|null } The first image URL found, or null if no image is found
38+ */
39+ const extractFirstImage = ( html ) => {
40+ // Regex pattern explanation:
41+ // <img - matches the opening img tag
42+ // [^>]+ - matches one or more characters that are not '>'
43+ // src=" - matches the src attribute opening
44+ // ([^">]+) - captures one or more characters that are not '"' or '>' (the URL)
45+ // " - matches the closing quote
46+ const imgRegex = / < i m g [ ^ > ] + s r c = " ( [ ^ " > ] + ) " / ;
47+ const match = html . match ( imgRegex ) ;
48+
49+ // match[0] would contain the full match (e.g., '<img src="https://example.com/image.jpg">')
50+ // match[1] contains just the URL from the capturing group (e.g., 'https://example.com/image.jpg')
51+ return match ? match [ 1 ] : null ;
52+ } ;
53+
54+ /**
55+ * Extracts the first image URL from Lexical content
56+ * @param {Object } lexical - The Lexical content object
57+ * @returns {string|null } The first image URL found, or null if no image is found
58+ */
59+ const extractFirstImageFromLexical = ( lexical ) => {
60+ try {
61+ const content = JSON . parse ( lexical ) ;
62+ // Lexical stores images in the root array with type 'image'
63+ const imageNode = content . root . children . find ( node => node . type === 'image' ) ;
64+ return imageNode ? imageNode . src : null ;
65+ } catch ( error ) {
66+ return null ;
67+ }
68+ } ;
69+
70+ /**
71+ * Extracts the first image URL from Mobiledoc content
72+ * @param {string } mobiledoc - The Mobiledoc content
73+ * @returns {string|null } The first image URL found, or null if no image is found
74+ */
75+ const extractFirstImageFromMobiledoc = ( mobiledoc ) => {
76+ try {
77+ const content = JSON . parse ( mobiledoc ) ;
78+ // Mobiledoc stores images in the cards array
79+ const imageCard = content . cards . find ( card => card [ 0 ] === 'image' ) ;
80+ return imageCard ? imageCard [ 1 ] . src : null ;
81+ } catch ( error ) {
82+ return null ;
83+ }
84+ } ;
85+
86+ const getFullTaskList = ( options ) => {
87+ return [
88+ initialise ( options ) ,
89+ {
90+ title : 'Fetching posts without featured images' ,
91+ task : async ( ctx , task ) => {
92+ let postDiscoveryOptions = {
93+ api : ctx . api ,
94+ type : 'posts' ,
95+ limit : 100 ,
96+ include : 'tags,authors' ,
97+ filter : 'feature_image:null' ,
98+ progress : ( options . verbose ) ? true : false
99+ } ;
100+
101+ try {
102+ ctx . posts = await discover ( postDiscoveryOptions ) ;
103+ task . output = `Found ${ ctx . posts . length } posts without featured images` ;
104+ } catch ( error ) {
105+ ctx . errors . push ( error ) ;
106+ throw error ;
107+ }
108+ }
109+ } ,
110+ {
111+ title : 'Processing posts and setting featured images' ,
112+ task : async ( ctx , task ) => {
113+ for ( const post of ctx . posts ) {
114+ try {
115+ if ( options . verbose ) {
116+ task . output = `Processing post "${ post . title } "` ;
117+ }
118+
119+ let firstImage = null ;
120+
121+ // Try Lexical first
122+ if ( post . lexical ) {
123+ firstImage = extractFirstImageFromLexical ( post . lexical ) ;
124+ if ( options . verbose && firstImage ) {
125+ task . output = `Found image in Lexical content: ${ firstImage } ` ;
126+ }
127+ }
128+
129+ // If no image found in Lexical, try Mobiledoc
130+ if ( ! firstImage && post . mobiledoc ) {
131+ firstImage = extractFirstImageFromMobiledoc ( post . mobiledoc ) ;
132+ if ( options . verbose && firstImage ) {
133+ task . output = `Found image in Mobiledoc content: ${ firstImage } ` ;
134+ }
135+ }
136+
137+ // If still no image, try HTML as fallback
138+ if ( ! firstImage && post . html ) {
139+ firstImage = extractFirstImage ( post . html ) ;
140+ if ( options . verbose && firstImage ) {
141+ task . output = `Found image in HTML content: ${ firstImage } ` ;
142+ }
143+ }
144+
145+ if ( firstImage ) {
146+ if ( options . verbose ) {
147+ task . output = `Updating post "${ post . title } " with image: ${ firstImage } ` ;
148+ }
149+
150+ await ctx . api . posts . edit ( {
151+ id : post . id ,
152+ feature_image : firstImage ,
153+ title : post . title ,
154+ status : post . status ,
155+ updated_at : post . updated_at
156+ } ) ;
157+ ctx . updated = ctx . updated + 1 ;
158+
159+ if ( options . verbose ) {
160+ task . output = `Successfully updated post "${ post . title } " with image: ${ firstImage } ` ;
161+ }
162+ } else if ( options . verbose ) {
163+ task . output = `No image found in post "${ post . title } "` ;
164+ }
165+
166+ ctx . processed = ctx . processed + 1 ;
167+
168+ // Add delay between API calls
169+ if ( ctx . args . delayBetweenCalls > 0 ) {
170+ await new Promise ( ( resolve ) => {
171+ setTimeout ( resolve , ctx . args . delayBetweenCalls ) ;
172+ } ) ;
173+ }
174+ } catch ( error ) {
175+ ctx . errors . push ( `Error processing post "${ post . title } ": ${ error . message } ` ) ;
176+ if ( options . verbose ) {
177+ task . output = `Error processing post "${ post . title } ": ${ error . message } ` ;
178+ }
179+ }
180+ }
181+
182+ task . output = `Processed ${ ctx . processed } posts, updated ${ ctx . updated } with featured images` ;
183+ }
184+ }
185+ ] ;
186+ } ;
187+
188+ const getTaskRunner = ( options ) => {
189+ let tasks = [ ] ;
190+ tasks = getFullTaskList ( options ) ;
191+ return makeTaskRunner ( tasks , Object . assign ( { topLevel : true } , options ) ) ;
192+ } ;
193+
194+ export {
195+ extractFirstImage ,
196+ extractFirstImageFromLexical ,
197+ extractFirstImageFromMobiledoc
198+ } ;
199+
200+ export default {
201+ initialise,
202+ getFullTaskList,
203+ getTaskRunner
204+ } ;
0 commit comments