Skip to content

Commit 6376a4d

Browse files
authored
Added command to set featured images on posts without a featured image as the first image in the post (#592)
1 parent a31d448 commit 6376a4d

File tree

4 files changed

+409
-0
lines changed

4 files changed

+409
-0
lines changed

bin/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import postTiers from '../commands/post-tiers.js';
3737
import pageToPost from '../commands/page-to-post.js';
3838
import getPosts from '../commands/get-posts.js';
3939
import setTemplate from '../commands/set-template.js';
40+
import setFeaturedImages from '../commands/set-featured-images.js';
4041

4142
prettyCLI.command(addMemberCompSubscriptionCommands);
4243
prettyCLI.command(removeMemberCompSubscriptionCommands);
@@ -72,6 +73,7 @@ prettyCLI.command(postTiers);
7273
prettyCLI.command(pageToPost);
7374
prettyCLI.command(getPosts);
7475
prettyCLI.command(setTemplate);
76+
prettyCLI.command(setFeaturedImages);
7577

7678
prettyCLI.style({
7779
usageCommandPlaceholder: () => '<source or utility>'

commands/set-featured-images.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {ui} from '@tryghost/pretty-cli';
2+
import setFeaturedImages from '../tasks/set-featured-images.js';
3+
4+
// Internal ID in case we need one.
5+
const id = 'set-featured-images';
6+
7+
const group = 'Content:';
8+
9+
// The command to run and any params
10+
const flags = 'set-featured-images <apiURL> <adminAPIKey>';
11+
12+
// Description for the top level command
13+
const desc = 'Set featured images for posts that don\'t have one by using the first image in the post content';
14+
15+
// Descriptions for the individual params
16+
const paramsDesc = [
17+
'URL to your Ghost API',
18+
'Admin API key'
19+
];
20+
21+
// Configure all the options
22+
const setup = (sywac) => {
23+
sywac.boolean('-V --verbose', {
24+
defaultValue: false,
25+
desc: 'Show verbose output'
26+
});
27+
sywac.number('--delayBetweenCalls', {
28+
defaultValue: 50,
29+
desc: 'The delay between API calls, in ms'
30+
});
31+
};
32+
33+
// What to do when this command is executed
34+
const run = async (argv) => {
35+
let timer = Date.now();
36+
let context = {errors: []};
37+
38+
try {
39+
// Fetch the tasks, configured correctly according to the options passed in
40+
let runner = setFeaturedImages.getTaskRunner(argv);
41+
42+
// Run the migration
43+
await runner.run(context);
44+
} catch (error) {
45+
ui.log.error('Done with errors', context.errors);
46+
}
47+
48+
// Report success
49+
ui.log.ok(`Successfully processed ${context.processed} posts in ${Date.now() - timer}ms.`);
50+
};
51+
52+
export default {
53+
id,
54+
group,
55+
flags,
56+
desc,
57+
paramsDesc,
58+
setup,
59+
run
60+
};

tasks/set-featured-images.js

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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 = /<img[^>]+src="([^">]+)"/;
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

Comments
 (0)