fix: improve naver blog bulk import flow

This commit is contained in:
hyungyunlim 2026-05-14 00:53:47 +09:00
parent e9612bd93c
commit 01b51b73ee
9 changed files with 126 additions and 90 deletions

102
main.ts
View file

@ -66,7 +66,7 @@ export default class NaverBlogPlugin extends Plugin {
this.aiService = new AIService(this.settings);
// Initialize Blog service
this.blogService = new BlogService(this.app, this.settings, this.createMarkdownFile.bind(this) as (post: ProcessedBlogPost) => Promise<void>);
this.blogService = new BlogService(this.app, this.settings, this.createMarkdownFile.bind(this) as (post: ProcessedBlogPost) => Promise<unknown>);
// Initialize Image service
this.imageService = new ImageService(this.app, this.settings);
@ -264,9 +264,28 @@ export default class NaverBlogPlugin extends Plugin {
return await this.blogService.fetchNaverBlogPosts(blogId, maxPosts);
}
private async ensureFolderExists(folderPath: string): Promise<void> {
const normalizedPath = normalizePath(folderPath);
if (!normalizedPath) return;
const parts = normalizedPath.split('/').filter(Boolean);
let currentPath = '';
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const existing = this.app.vault.getAbstractFileByPath(currentPath);
if (!existing) {
await this.app.vault.createFolder(currentPath);
} else if (existing instanceof TFile) {
throw new Error(`Cannot create folder "${currentPath}" because a file exists at that path`);
}
}
}
async callAI(messages: Array<{role: string, content: string}>, maxTokens: number = 150): Promise<string> {
const apiKey = APIClientFactory.getApiKey(this.settings);
if (!apiKey) {
if (!apiKey && this.settings.aiProvider !== 'ollama') {
throw new Error('No API key configured for selected AI provider');
}
@ -276,6 +295,10 @@ export default class NaverBlogPlugin extends Plugin {
return await client.chat(messages, maxTokens, model);
}
private isAIAvailable(): boolean {
return this.settings.aiProvider === 'ollama' || APIClientFactory.getApiKey(this.settings).trim().length > 0;
}
getModelName(): string {
return APIClientFactory.getModelName(this.settings);
}
@ -343,7 +366,7 @@ export default class NaverBlogPlugin extends Plugin {
async generateAITags(title: string, content: string): Promise<string[]> {
if (!this.settings.enableAiTags) {
if (!this.settings.enableAiTags || !this.isAIAvailable()) {
return [];
}
@ -404,7 +427,7 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
}
async generateAIExcerpt(title: string, content: string): Promise<string> {
if (!this.settings.enableAiExcerpt) {
if (!this.settings.enableAiExcerpt || !this.isAIAvailable()) {
return '';
}
@ -437,7 +460,8 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
async createMarkdownFile(post: ProcessedBlogPost): Promise<TFile | null> {
try {
// Generate AI tags and append to original tags (avoid duplicates)
if (this.settings.enableAiTags) {
const shouldUseAI = this.isAIAvailable();
if (this.settings.enableAiTags && shouldUseAI) {
const aiTags = await this.generateAITags(post.title, post.content);
if (aiTags && aiTags.length > 0) {
const existingTags = new Set(post.tags.map(t => t.toLowerCase()));
@ -446,7 +470,7 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
}
}
if (this.settings.enableAiExcerpt) {
if (this.settings.enableAiExcerpt && shouldUseAI) {
// Add small delay between AI calls to avoid rate limiting
if (this.settings.enableAiTags) {
await new Promise(resolve => window.setTimeout(resolve, API_DELAYS.betweenPosts));
@ -462,6 +486,8 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
// Blog images/videos go to attachments subfolder inside the blog folder
const blogImageFolder = normalizePath(`${folder}/attachments`);
await this.ensureFolderExists(folder);
// Process images if enabled
let processedContent = post.content;
if (this.settings.enableImageDownload) {
@ -479,18 +505,6 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
);
}
// Ensure base folder exists
const baseFolderExists = this.app.vault.getAbstractFileByPath(baseFolder);
if (!baseFolderExists) {
await this.app.vault.createFolder(baseFolder);
}
// Ensure blogId subfolder exists
const folderExists = this.app.vault.getAbstractFileByPath(folder);
if (!folderExists) {
await this.app.vault.createFolder(folder);
}
const filepath = normalizePath(`${folder}/${filename}`);
// Create frontmatter
@ -511,20 +525,22 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
new Notice(`Created: ${filename}`);
return createdFile;
} catch {
} catch (error) {
console.error(`Failed to create file for: ${post.title}`, error);
new Notice(`Failed to create file for: ${post.title}`);
return null;
throw error;
}
}
async createCafeMarkdownFile(post: ProcessedCafePost): Promise<TFile | null> {
try {
// Generate AI tags and excerpt if enabled
if (this.settings.enableAiTags) {
const shouldUseAI = this.isAIAvailable();
if (this.settings.enableAiTags && shouldUseAI) {
post.tags = await this.generateAITags(post.title, post.content);
}
if (this.settings.enableAiExcerpt) {
if (this.settings.enableAiExcerpt && shouldUseAI) {
if (this.settings.enableAiTags) {
await new Promise(resolve => window.setTimeout(resolve, API_DELAYS.betweenPosts));
}
@ -540,6 +556,8 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
// Cafe images go to attachments subfolder inside the cafe folder
const cafeImageFolder = normalizePath(`${folder}/attachments`);
await this.ensureFolderExists(folder);
// Process images if enabled (use cafeSettings if available, otherwise use blog's enableImageDownload)
let processedContent = post.content;
const shouldDownloadImages = this.settings.cafeSettings?.downloadCafeImages ?? this.settings.enableImageDownload;
@ -559,18 +577,6 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
);
}
// Ensure base folder exists
const baseFolderExists = this.app.vault.getAbstractFileByPath(baseFolder);
if (!baseFolderExists) {
await this.app.vault.createFolder(baseFolder);
}
// Ensure cafeName subfolder exists
const folderExists = this.app.vault.getAbstractFileByPath(folder);
if (!folderExists) {
await this.app.vault.createFolder(folder);
}
const filepath = normalizePath(`${folder}/${filename}`);
// Create frontmatter for cafe post
@ -597,9 +603,10 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
new Notice(`Created: ${filename}`);
return createdFile;
} catch {
} catch (error) {
console.error(`Failed to create file for: ${post.title}`, error);
new Notice(`Failed to create file for: ${post.title}`);
return null;
throw error;
}
}
@ -644,7 +651,7 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
async createBrunchMarkdownFile(post: ProcessedBrunchPost): Promise<TFile | null> {
try {
// Generate AI tags if enabled and no original tags
if (this.settings.enableAiTags && (!post.originalTags || post.originalTags.length === 0)) {
if (this.settings.enableAiTags && this.isAIAvailable() && (!post.originalTags || post.originalTags.length === 0)) {
post.originalTags = await this.generateAITags(post.title, post.content);
}
@ -659,6 +666,8 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
// Brunch images go to attachments subfolder
const brunchImageFolder = normalizePath(`${folder}/attachments`);
await this.ensureFolderExists(folder);
// Process images if enabled
let processedContent = post.content;
const shouldDownloadImages = this.settings.brunchSettings?.downloadBrunchImages ?? this.settings.enableImageDownload;
@ -687,18 +696,6 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
}
}
// Ensure base folder exists
const baseFolderExists = this.app.vault.getAbstractFileByPath(baseFolder);
if (!baseFolderExists) {
await this.app.vault.createFolder(baseFolder);
}
// Ensure username subfolder exists
const folderExists = this.app.vault.getAbstractFileByPath(folder);
if (!folderExists) {
await this.app.vault.createFolder(folder);
}
const filepath = normalizePath(`${folder}/${filename}`);
// Create frontmatter
@ -718,9 +715,10 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
new Notice(`Created: ${filename}`);
return createdFile;
} catch {
} catch (error) {
console.error(`Failed to create file for: ${post.title}`, error);
new Notice(`Failed to create file for: ${post.title}`);
return null;
throw error;
}
}
@ -1019,4 +1017,4 @@ JSON 배열로만 응답하세요. 예: ["리뷰", "기술", "일상"]`
new Notice(this.i18n.t('notices.delete_failed', { error: message }), NOTICE_TIMEOUTS.medium);
}
}
}
}

View file

@ -1,7 +1,7 @@
{
"id": "naver-blog-importer",
"name": "Naver Blog Importer",
"version": "1.10.2",
"version": "1.10.3",
"minAppVersion": "1.8.7",
"description": "Import posts from Naver Blog, Cafe, News, and Kakao Brunch with AI-powered features, subscription management, and comprehensive content parsing.",
"author": "hyungyunlim",

View file

@ -40,6 +40,11 @@ export interface NaverBlogPost {
originalTags: string[];
}
export interface NaverBlogFetchPostsOptions {
excludeLogNos?: Set<string>;
onProgress?: (current: number, total: number, post: Omit<NaverBlogPost, 'content' | 'blogId' | 'originalTags'>) => void;
}
export class NaverBlogFetcher {
private blogId: string;
@ -80,7 +85,7 @@ export class NaverBlogFetcher {
}
}
async fetchPosts(maxPosts?: number): Promise<NaverBlogPost[]> {
async fetchPosts(maxPosts?: number, options: NaverBlogFetchPostsOptions = {}): Promise<NaverBlogPost[]> {
try {
// Get blog post list
let posts = await this.getPostList(maxPosts);
@ -114,6 +119,10 @@ export class NaverBlogFetcher {
posts = posts.slice(0, maxPosts);
}
if (options.excludeLogNos && options.excludeLogNos.size > 0) {
posts = posts.filter(post => !options.excludeLogNos?.has(post.logNo));
}
// Fetch content for each post
const postsWithContent: NaverBlogPost[] = [];
@ -121,6 +130,7 @@ export class NaverBlogFetcher {
const post = posts[i];
try {
options.onProgress?.(i + 1, posts.length, post);
const parsed = await this.fetchPostContent(post.logNo);
postsWithContent.push({
title: parsed.title !== 'Untitled' ? parsed.title : post.title,
@ -136,7 +146,7 @@ export class NaverBlogFetcher {
// Add delay to be respectful to the server
await this.delay(1000);
await this.delay(250);
} catch (error) {
// Create error post for failed fetch
@ -155,7 +165,7 @@ export class NaverBlogFetcher {
// Add delay to be respectful to the server
await this.delay(500);
await this.delay(250);
}
}
@ -191,7 +201,8 @@ export class NaverBlogFetcher {
// Try multiple pages to get more posts
let currentPage = 1;
let hasMore = true;
const maxPages = maxPosts ? Math.min(Math.ceil(maxPosts / 30), 10) : 20; // Limit pages based on maxPosts, default to 20 pages (600 posts max)
const postsPerPage = 10;
const maxPages = maxPosts ? Math.ceil(maxPosts / postsPerPage) : 100; // Default cap: 100 pages (1000 posts max)
const postLimit = maxPosts || 1000; // Default to 1000 if no limit specified
while (hasMore && currentPage <= maxPages && posts.length < postLimit) {
@ -220,13 +231,15 @@ export class NaverBlogFetcher {
if (pagePosts.length > 0) {
// Add only new posts (avoid duplicates)
let newPostCount = 0;
for (const post of pagePosts) {
if (!posts.find(p => p.logNo === post.logNo)) {
posts.push(post);
newPostCount++;
}
}
foundPostsOnPage = true;
foundPostsOnPage = newPostCount > 0;
break; // Found posts with this URL pattern, no need to try others
}
}
@ -2028,4 +2041,4 @@ export interface NaverBlogProfile {
nickname: string;
profileImageUrl?: string;
bio?: string;
}
}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-naver-blog-importer",
"version": "1.10.2",
"version": "1.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-naver-blog-importer",
"version": "1.10.2",
"version": "1.10.3",
"license": "MIT",
"dependencies": {
"cheerio": "^1.0.0-rc.12",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-naver-blog-importer",
"version": "1.10.2",
"version": "1.10.3",
"description": "Import posts from Naver Blog directly into Obsidian",
"main": "main.js",
"scripts": {

View file

@ -16,7 +16,7 @@ export class BlogService {
constructor(
private app: App,
private settings: NaverBlogSettings,
private createMarkdownFile: (post: ProcessedBlogPost) => Promise<void>
private createMarkdownFile: (post: ProcessedBlogPost) => Promise<unknown>
) {}
async fetchNaverBlogPosts(blogId: string, maxPosts?: number, options?: SyncOptions): Promise<ProcessedBlogPost[]> {
@ -31,8 +31,16 @@ export class BlogService {
// Use settings value if maxPosts is not provided and settings value is > 0
const effectiveMaxPosts = maxPosts || (this.settings.postImportLimit > 0 ? this.settings.postImportLimit : undefined);
const existingLogNos = this.settings.enableDuplicateCheck ? this.getExistingLogNos() : undefined;
const fetcher = new NaverBlogFetcher(blogId);
const posts = await fetcher.fetchPosts(effectiveMaxPosts);
const posts = await fetcher.fetchPosts(effectiveMaxPosts, {
excludeLogNos: existingLogNos,
onProgress: silent ? undefined : (current, total, post) => {
if (total > 1) {
new Notice(`Fetching post content (${current}/${total}): ${post.title || post.logNo}`, 2500);
}
}
});
// Hide the persistent notice
if (fetchNotice) {
@ -40,24 +48,20 @@ export class BlogService {
fetchNotice = null;
}
// Filter out duplicates if enabled
let filteredPosts = posts;
if (this.settings.enableDuplicateCheck) {
const existingLogNos = this.getExistingLogNos();
filteredPosts = posts.filter(post => !existingLogNos.has(post.logNo));
if (!silent) {
new Notice(`Found ${posts.length} posts, ${filteredPosts.length} new posts after duplicate check`, 4000);
new Notice(`Found ${posts.length} new posts after duplicate check`, 4000);
}
} else if (!silent) {
new Notice(`Found ${posts.length} posts`, 4000);
}
if (!silent) {
new Notice(`Processing ${filteredPosts.length} posts...`, 3000);
new Notice(`Processing ${posts.length} posts...`, 3000);
}
// Convert to ProcessedBlogPost - use original tags from blog
const processedPosts: ProcessedBlogPost[] = filteredPosts.map(post => ({
const processedPosts: ProcessedBlogPost[] = posts.map(post => ({
...post,
title: post.title.replace(/^\[.*?\]\s*/, '').replace(/\s*\[.*?\]$/, '').trim(), // Remove [] brackets from title start/end
tags: post.originalTags || [],
@ -134,8 +138,10 @@ export class BlogService {
const postProgress = `(${i + 1}/${totalBlogs}) post (${j + 1}/${posts.length})`;
new Notice(`Creating ${postProgress}: ${post.title}`, 3000);
}
await this.createMarkdownFile(post);
result.newPosts++;
const createdFile = await this.createMarkdownFile(post);
if (createdFile) {
result.newPosts++;
}
} catch {
result.errors++;
}
@ -198,4 +204,4 @@ export class BlogService {
updateSettings(newSettings: NaverBlogSettings) {
this.settings = newSettings;
}
}
}

View file

@ -598,12 +598,15 @@ export class NaverBlogImportModal extends Modal {
excerpt: post.content.substring(0, 150) + '...'
});
if (!createdFile) {
new Notice(`Already imported: "${post.title}"`, NOTICE_TIMEOUTS.medium);
return;
}
new Notice(`✅ Imported: "${post.title}"`, NOTICE_TIMEOUTS.medium);
// Open the created file
if (createdFile) {
await this.openFile(createdFile);
}
await this.openFile(createdFile);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(`❌ Import failed: ${message}`, NOTICE_TIMEOUTS.medium);
@ -658,6 +661,7 @@ export class NaverBlogImportModal extends Modal {
let successCount = 0;
let errorCount = 0;
let errorLogCount = 0;
let skippedCount = 0;
let lastCreatedFile: TFile | null = null;
const totalPosts = posts.length;
@ -674,14 +678,16 @@ export class NaverBlogImportModal extends Modal {
if (createdFile) {
lastCreatedFile = createdFile;
}
if (isErrorPost) {
errorLogCount++;
if (isErrorPost) {
errorLogCount++;
} else {
successCount++;
}
} else {
successCount++;
skippedCount++;
}
} catch {
} catch (error) {
console.error(`Failed to create file for ${post.logNo}:`, error);
// Failed to create markdown file for this post
errorCount++;
}
@ -695,9 +701,10 @@ export class NaverBlogImportModal extends Modal {
`Import complete: ${successCount} successful`;
if (errorLogCount > 0) summary += `, ${errorLogCount} error logs`;
if (skippedCount > 0) summary += `, ${skippedCount} skipped`;
if (errorCount > 0) summary += `, ${errorCount} errors`;
const processed = successCount + errorLogCount + errorCount;
const processed = successCount + errorLogCount + skippedCount + errorCount;
summary += ` (${processed}/${totalPosts})`;
if (!importCancelled && errorCount === 0) summary += ' ✅';

View file

@ -551,12 +551,20 @@ export class NaverBlogSettingTab extends PluginSettingTab {
const posts = await this.plugin.fetchNaverBlogPosts(blogId, currentCount);
let successCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const post of posts) {
try {
await this.plugin.createMarkdownFile(post);
successCount++;
} catch {
const createdFile = await this.plugin.createMarkdownFile(post);
if (createdFile) {
successCount++;
} else {
skippedCount++;
}
} catch (error) {
console.error(`Failed to sync post ${post.logNo}:`, error);
// Skip failed post
errorCount++;
}
}
@ -570,7 +578,10 @@ export class NaverBlogSettingTab extends PluginSettingTab {
// Refresh display to show updated metadata
this.displaySubscriptions(containerEl);
new Notice(`Synced ${successCount} posts from ${subscription?.blogName || blogId}`);
let summary = `Synced ${successCount} posts from ${subscription?.blogName || blogId}`;
if (skippedCount > 0) summary += `, ${skippedCount} skipped`;
if (errorCount > 0) summary += `, ${errorCount} errors`;
new Notice(summary);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Failed to sync ${blogId}: ${message}`);
@ -955,4 +966,4 @@ export class NaverBlogSettingTab extends PluginSettingTab {
hideDropdown() {
// This will be overridden in setupFolderDropdown
}
}
}

View file

@ -35,5 +35,6 @@
"1.9.0": "0.15.0",
"1.10.0": "0.15.0",
"1.10.1": "0.15.0",
"1.10.2": "1.8.7"
"1.10.2": "1.8.7",
"1.10.3": "1.8.7"
}