1using System.Collections.ObjectModel;
6using System.Text.RegularExpressions;
7using Dreamine.MVVM.Attributes;
8using Dreamine.MVVM.ViewModels;
72 public override string ToString() => Label;
83public sealed record
WaitOption(
int Seconds,
string Label)
101 public override string ToString() => Label;
218 private readonly HashSet<string>
_usedImageUrls =
new(StringComparer.OrdinalIgnoreCase);
256 public ObservableCollection<string>
Slugs {
get; } = [];
265 public ObservableCollection<AlbumInfo>
Albums {
get; } = [];
278 new(5,
"5분"),
new(10,
"10분"),
new(15,
"15분"),
279 new(20,
"20분"),
new(30,
"30분"),
new(60,
"1시간"),
280 new(120,
"2시간"),
new(180,
"3시간"),
new(360,
"6시간"),
292 new(15,
"15초"),
new(20,
"20초"),
new(30,
"30초"),
293 new(45,
"45초"),
new(60,
"1분"),
new(90,
"1분 30초"),
294 new(120,
"2분"),
new(150,
"2분 30초"),
new(180,
"3분"),
555 _uiDispatcher = System.Windows.Application.Current.Dispatcher;
558 if (initialPrompt !=
null)
_promptText = initialPrompt;
618 var dlg =
new OpenFolderDialog { Title =
"Families.Web App_Data 폴더 선택" };
619 if (dlg.ShowDialog() ==
true)
AppDataRoot = dlg.FolderName;
698 _loopCts =
new CancellationTokenSource();
702 var nc = NewChatEveryN > 0 ? $
" | 🔄{NewChatEveryN}개" :
"";
703 LoopStatus = $
"✅ {SelectedInterval?.Label} | AI대기 {AiWaitOption?.Label}{nc}";
721 LoopStatus =
"⏸ 루프 꺼짐";
769 while (!ct.IsCancellationRequested)
774 while (!ct.IsCancellationRequested && DateTime.Now <
_nextLoopAt)
777 if (r > TimeSpan.Zero)
779 var text = r.TotalHours >= 1
780 ? $
"다음 {(int)r.TotalHours}:{r.Minutes:D2}:{r.Seconds:D2}"
781 : $
"다음 {r.Minutes:D2}:{r.Seconds:D2}";
784 await Task.Delay(1000, ct).ConfigureAwait(
false);
787 catch (OperationCanceledException) {
break; }
789 if (ct.IsCancellationRequested)
break;
795 catch (OperationCanceledException) {
break; }
798 await
_uiDispatcher.InvokeAsync(() => LoopStatus = $
"❌ 루프 오류: {ex.Message}");
799 await Task.Delay(3000, ct).ConfigureAwait(
false);
802 if (!ct.IsCancellationRequested)
806 catch (OperationCanceledException) { }
809 await
_uiDispatcher.InvokeAsync(() => LoopStatus = $
"❌ 루프 중단: {ex.Message}");
845 var (slug, album, prompt, execFn) = await
_uiDispatcher.InvokeAsync(() =>
848 if (
string.IsNullOrWhiteSpace(slug)) { await
SetStatus(
"❌ 슬러그를 선택하세요.");
return; }
849 if (
string.IsNullOrWhiteSpace(prompt)) { await
SetStatus(
"❌ 프롬프트를 입력하세요.");
return; }
850 if (execFn ==
null) { await
SetStatus(
"❌ 브라우저 준비 안 됨");
return; }
852 var albumName = album?.Name ??
"전체 타임라인";
853 var filled = prompt.Replace(
"{앨범}", albumName).Replace(
"{album}", albumName);
860 string baselineText =
"";
863 using var bd = System.Text.Json.JsonDocument.Parse(baselineRaw ??
"{}");
864 baselineText = bd.RootElement.TryGetProperty(
"text", out var bt) ? bt.GetString() ??
"" :
"";
868 await
SetStatus($
"📤 [{albumName}] 전송 중...");
872 await
SetStatus($
"⏳ [{albumName}] AI 시작 대기...");
873 await Task.Delay(10_000, ct).ConfigureAwait(
false);
879 string prevText =
"";
881 for (
int retry = 0; retry <= 60; retry++)
883 if (ct.IsCancellationRequested)
return;
887 bool completed =
false;
890 using var doc = System.Text.Json.JsonDocument.Parse(extracted ??
"{}");
891 curText = doc.RootElement.TryGetProperty(
"text", out var t) ? t.GetString() ??
"" :
"";
892 completed = doc.RootElement.TryGetProperty(
"completed", out var c) && c.GetBoolean();
894 catch { curText = extracted ??
""; }
897 bool isNewResponse = curText.Length > 20 && curText != baselineText;
900 if (completed && isNewResponse)
907 var hasEndMarker = curText.Contains(
"===VIDEO===", StringComparison.OrdinalIgnoreCase);
908 if (isNewResponse && hasEndMarker && curText == prevText)
911 if (stableCount >= 2)
923 var elapsed = 8 + retry * 15;
924 await
SetStatus($
"⏳ [{albumName}] AI 생성 중... ({elapsed}s)");
925 await Task.Delay(15_000, ct).ConfigureAwait(
false);
930 await
SetStatus(
"⏱ AI 응답 대기 시간 초과 (응답 없음)");
936 var albumId = album?.Id ??
"";
937 var newChatN = await
_uiDispatcher.InvokeAsync(() => NewChatEveryN);
938 var rotEnabled = await
_uiDispatcher.InvokeAsync(() => AlbumRotationEnabled);
945 if (posts.Count == 0)
949 await
SetStatus(
"❌ 요리 포맷 추출 실패 (===DETAIL_NNN=== 섹션 없음)");
953 await
SetStatus($
"⏳ [{albumName}] AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})");
960 var existingCookingTitles =
_writer.GetExistingTitles(slug);
961 var existingCookingNumbers = existingCookingTitles
965 var newPosts = posts.Where(p =>
966 !existingCookingTitles.Any(t =>
string.Equals(t, p.Title, StringComparison.OrdinalIgnoreCase)) &&
969 if (newPosts.Count == 0) { await
SetStatus($
"⏭ [{albumName}] 모두 중복 제목 스킵");
return; }
971 foreach (var p
in newPosts) await
_writer.SavePostAsync(slug, p, []);
973 await
SetStatus($
"✅ [{albumName}] #{_cycleCount} {newPosts.Count}개 저장! ({DateTime.Now:HH:mm})");
979 if (result is
null ||
string.IsNullOrWhiteSpace(result.Content))
983 await
SetStatus(
"❌ 추출 실패 (===CONTENT=== 섹션 없음 — AI 응답 불완전)");
987 await
SetStatus($
"⏳ [{albumName}] AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})");
993 List<string> validPhotos = [];
994 if (result.Photos.Count > 0)
998 validPhotos = allValid.Take(3).ToList();
1002 var postTitle = !
string.IsNullOrWhiteSpace(result.Title) ? result.Title : $
"[{albumName}] {DateTime.Now:MM/dd HH:mm}";
1005 var existingTitles =
_writer.GetExistingTitles(slug);
1006 if (existingTitles.Any(t =>
string.Equals(t, postTitle, StringComparison.OrdinalIgnoreCase)))
1008 await
SetStatus($
"⏭ [{albumName}] 중복 제목 스킵: {postTitle[..Math.Min(30, postTitle.Length)]}...");
1017 PostedAt = DateTime.Now,
1019 PhotoFileNames = validPhotos,
1020 VideoFileNames = result.Videos,
1022 await
_writer.SavePostAsync(slug, post, []);
1024 await
SetStatus($
"✅ [{albumName}] #{_cycleCount} 저장! ({DateTime.Now:HH:mm}) 📷{validPhotos.Count} 🎬{result.Videos.Count}");
1037 await Task.Delay(2000, ct).ConfigureAwait(
false);
1040 for (
int i = 15; i > 0; i--)
1042 if (ct.IsCancellationRequested)
return;
1043 await
SetStatus($
"🔄 새 채팅 로딩 대기 {i}초...");
1044 await Task.Delay(1000, ct).ConfigureAwait(
false);
1049 catch (OperationCanceledException) { await
SetStatus(
"⏹ 취소됨"); }
1050 catch (Exception ex) { await
SetStatus($
"❌ {ex.Message}"); }
1089 if (
Albums.Count == 0)
return;
1113 if (
string.IsNullOrWhiteSpace(PromptText)) { PromptStatus =
"❌ 프롬프트를 입력하세요.";
return; }
1116 var prompt = PromptText.Replace(
"{앨범}", albumName).Replace(
"{album}", albumName);
1118 PromptStatus = $
"✅ 전송 ({DateTime.Now:HH:mm})";
1139 if (
string.IsNullOrWhiteSpace(
SelectedSlug)) { ExtractStatus =
"❌ 슬러그를 선택하세요.";
return; }
1142 ExtractStatus =
"⏳ AI 응답 추출 중...";
1152 if (posts.Count == 0)
1156 ExtractStatus =
"❌ 요리 포맷 추출 실패 (===DETAIL_NNN_CONTENT=== 없음)";
1160 ExtractStatus = $
"⏳ AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})";
1167 var existingCookingNumbers = existingCookingTitles
1171 var newPosts = posts.Where(p =>
1172 !existingCookingTitles.Any(t =>
string.Equals(t, p.Title, StringComparison.OrdinalIgnoreCase)) &&
1175 if (newPosts.Count == 0) { ExtractStatus =
"⏭ 모두 중복 제목/번호 스킵";
return; }
1178 ExtractStatus = $
"✅ {newPosts.Count}개 저장! ({DateTime.Now:HH:mm})";
1184 if (result is
null ||
string.IsNullOrWhiteSpace(result.Content))
1188 ExtractStatus =
"❌ 응답 없음";
1192 ExtractStatus = $
"⏳ AI 응답 불완전 - {ExtractionRetryDelaySeconds}초 후 재추출 ({extractAttempt}/{ExtractionRetryMaxAttempts})";
1199 List<string> validPhotos = [];
1200 if (result.Photos.Count > 0)
1202 ExtractStatus =
"🔍 이미지 검증...";
1204 validPhotos = allValid.Take(3).ToList();
1210 Title = !
string.IsNullOrWhiteSpace(result.Title) ? result.Title : $
"[{albumName}] {DateTime.Now:MM/dd HH:mm}",
1214 PostedAt = DateTime.Now,
1215 PhotoFileNames = validPhotos,
1216 VideoFileNames = result.Videos,
1219 ExtractStatus = $
"✅ [{albumName}] 저장! ({DateTime.Now:HH:mm}) 📷{validPhotos.Count} 🎬{result.Videos.Count}";
1224 catch (Exception ex) { ExtractStatus = $
"❌ {ex.Message}"; }
1236 private sealed record
AiPostResult(
string Title,
string Content, List<string> Photos, List<string> Videos);
1265 if (
string.IsNullOrWhiteSpace(raw))
return "";
1268 var u = JsonSerializer.Deserialize<
string>(raw) ?? raw;
1269 var d = JsonDocument.Parse(u);
1270 if (d.RootElement.TryGetProperty(
"text", out var tp))
return tp.GetString() ?? u;
1273 catch {
return raw.Trim(
'"').Replace(
"\\n",
"\n").Replace(
"\\\"",
"\""); }
1311 var marker = $
"==={tag}===";
1312 var s = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
1313 if (s < 0)
return "";
1315 var n = text.IndexOf(
"===", s);
1316 return text[s..(n > s ? n : text.Length)].Trim();
1364 string[] ordinals = [
"첫 번째",
"두 번째",
"세 번째",
"네 번째",
"다섯 번째"];
1365 var result = content;
1367 for (
int i = 0; i < urls.Count; i++)
1370 if (i < ordinals.Length)
1371 result = result.Replace($
"[IMAGES {ordinals[i]} URL]", urls[i], StringComparison.OrdinalIgnoreCase);
1375 if (offset > 0 && offset < ordinals.Length && offset < urls.Count + offset)
1378 result = result.Replace(
"[IMAGES 첫 번째 URL]", urls.Count > 0 ? urls[0] :
"", StringComparison.OrdinalIgnoreCase);
1382 result = System.Text.RegularExpressions.Regex.Replace(
1384 @"<img\s[^>]*src=""[^""]*\[IMAGES[^\]]*\][^""]*""[^>]*/?>",
1386 System.Text.RegularExpressions.RegexOptions.IgnoreCase);
1419 if (
string.IsNullOrWhiteSpace(text))
return null;
1422 if (
string.IsNullOrWhiteSpace(content))
1425 var titleEnd = text.IndexOf(
"===TITLE===", StringComparison.OrdinalIgnoreCase);
1426 var imagesIdx = text.IndexOf(
"===IMAGES===", StringComparison.OrdinalIgnoreCase);
1427 if (titleEnd >= 0 && imagesIdx > titleEnd)
1429 var afterTitle = text.IndexOf(
'\n', titleEnd +
"===TITLE===".Length);
1430 if (afterTitle >= 0)
1432 var afterTitleLine = text.IndexOf(
'\n', afterTitle + 1);
1433 if (afterTitleLine >= 0 && afterTitleLine < imagesIdx)
1434 content = text[afterTitleLine..imagesIdx].Trim();
1437 if (
string.IsNullOrWhiteSpace(content))
return null;
1441 .Split(
'\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1442 .Where(l => l.StartsWith(
"http", StringComparison.OrdinalIgnoreCase))
1446 .Split(
'\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1482 if (
string.IsNullOrWhiteSpace(text))
return [];
1485 var rawImageUrls =
GetSection(text,
"IMAGES")
1486 .Split(
'\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1487 .Where(l => l.StartsWith(
"http", StringComparison.OrdinalIgnoreCase))
1491 var validImages = rawImageUrls.Count > 0
1495 const int CookingImagesPerPost = 2;
1496 var imageUrls = validImages.ToArray();
1498 var posts =
new List<PostEntry>();
1501 var nextNumber = timeline.LastNumber > 0 ? timeline.LastNumber + 1 : 1;
1505 var detailNums = Regex
1506 .Matches(text,
@"===DETAIL_(?<num>\d+|\[[^\]]+\])_TITLE===", RegexOptions.IgnoreCase)
1507 .Select(m => m.Groups[
"num"].Value)
1509 .OrderBy(n =>
int.TryParse(n, out var i) ? i :
int.MaxValue)
1514 foreach (var num
in detailNums)
1516 var title =
GetSection(text, $
"DETAIL_{num}_TITLE");
1517 var date =
GetSection(text, $
"DETAIL_{num}_DATE");
1518 var content =
GetSection(text, $
"DETAIL_{num}_CONTENT");
1520 if (
string.IsNullOrWhiteSpace(content))
continue;
1521 var displayNum =
int.TryParse(num, out _) ? num : $
"{DateTime.Now:HHmm}{posts.Count + 1}";
1522 var postedAt = nextPostedAt;
1527 var postImages = imageUrls
1529 .Take(CookingImagesPerPost)
1531 imgIdx += CookingImagesPerPost;
1542 Title =
string.IsNullOrWhiteSpace(title) ? $
"[1983 집밥육아 #{nextNumber}] {mealName} 집밥 기록" : title,
1543 Content = substituted,
1545 PostedAt = postedAt,
1547 PhotoFileNames =
MergeUrls(postImages, bodyImages),
1548 VideoFileNames = [],
1584 if (lastPostedAt is
null)
1585 return new DateTime(1983, 1, 1, 8, 0, 0);
1587 var last = lastPostedAt.Value;
1588 return last.Hour
switch
1590 < 12 => last.Date.AddHours(12),
1591 < 18 => last.Date.AddHours(18),
1592 _ => last.Date.AddDays(1).AddHours(8),
1621 postedAt.Hour
switch
1670 var normalized =
string.IsNullOrWhiteSpace(title)
1671 ? $
"[1983 집밥육아 #{number}] {mealName} 집밥 기록"
1674 normalized = Regex.Replace(normalized,
@"#\d+|#번호\d+|#새숫자", $
"#{number}", RegexOptions.IgnoreCase);
1675 normalized = normalized
1676 .Replace(
"[번호1]", number.ToString(), StringComparison.OrdinalIgnoreCase)
1677 .Replace(
"[번호2]", number.ToString(), StringComparison.OrdinalIgnoreCase)
1678 .Replace(
"[번호3]", number.ToString(), StringComparison.OrdinalIgnoreCase)
1679 .Replace(
"[새숫자]", number.ToString(), StringComparison.OrdinalIgnoreCase);
1681 if (!Regex.IsMatch(normalized,
@"#\d+"))
1682 normalized = $
"[1983 집밥육아 #{number}] {normalized}";
1684 normalized = Regex.IsMatch(normalized,
@"아침|점심|저녁")
1685 ? Regex.Replace(normalized,
@"아침|점심|저녁", mealName, RegexOptions.None, TimeSpan.FromMilliseconds(100))
1686 : Regex.Replace(normalized,
@"(\]\s*)",
"$1" + mealName +
" ", RegexOptions.None, TimeSpan.FromMilliseconds(100));
1717 var match = Regex.Match(title ??
"",
@"#(?<n>\d+)");
1718 return match.Success &&
int.TryParse(match.Groups[
"n"].Value, out var number) ? number : 0;
1746 Regex.Matches(content,
@"<img\b[^>]*\bsrc\s*=\s*[""'](?<url>https?://[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase)
1747 .Select(m => System.Net.WebUtility.HtmlDecode(m.Groups[
"url"].Value.Trim()))
1748 .Where(u => u.StartsWith(
"http", StringComparison.OrdinalIgnoreCase))
1785 if (
string.IsNullOrWhiteSpace(content))
1788 var allowed = allowedUrls
1789 .Select(u => System.Net.WebUtility.HtmlDecode(u).Trim())
1790 .Where(u => u.Length > 0)
1791 .ToHashSet(StringComparer.OrdinalIgnoreCase);
1793 return Regex.Replace(
1798 var src = Regex.Match(match.Value,
@"\bsrc\s*=\s*[""'](?<url>https?://[^""']+)[""']", RegexOptions.IgnoreCase);
1802 var url = System.Net.WebUtility.HtmlDecode(src.Groups[
"url"].Value.Trim());
1803 return allowed.Contains(url) ? match.Value :
"";
1805 RegexOptions.IgnoreCase);
1840 private static List<string>
MergeUrls(IEnumerable<string> primary, IEnumerable<string> secondary)
1842 var merged =
new List<string>();
1843 foreach (var url
in primary.Concat(secondary))
1845 var clean = url.Trim();
1846 if (clean.Length == 0)
continue;
1847 if (!merged.Contains(clean, StringComparer.OrdinalIgnoreCase))
1879 var text =
NormalizeKnownHeadings(content,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
1881 [
"한눈에 보는 포인트"] =
"## 한눈에 보는 포인트",
1882 [
"이런 가족에게 추천"] =
"## 이런 가족에게 추천",
1883 [
"아이와 좋았던 점"] =
"## 아이와 좋았던 점",
1884 [
"동선과 시간표"] =
"## 동선과 시간표",
1885 [
"여행 경비 예상"] =
"## 여행 경비 예상",
1886 [
"주차·화장실·유모차 포인트"] =
"## 주차·화장실·유모차 포인트",
1887 [
"주차・화장실・유모차 포인트"] =
"## 주차·화장실·유모차 포인트",
1888 [
"준비물과 주의할 것"] =
"## 준비물과 주의할 것",
1889 [
"방문 전 확인할 것"] =
"## 방문 전 확인할 것",
1922 if (!url.StartsWith(
"http", StringComparison.OrdinalIgnoreCase))
return false;
1923 if (!url.Contains(
"youtube.com", StringComparison.OrdinalIgnoreCase) &&
1924 !url.Contains(
"youtu.be/", StringComparison.OrdinalIgnoreCase))
1928 return url.Contains(
"/embed/", StringComparison.OrdinalIgnoreCase);
1957 var text = Regex.Replace(
1961 RegexOptions.IgnoreCase);
1965 [
"오늘의 아기 밥"] =
"## 오늘의 아기 밥",
1966 [
"오늘의 가족 집밥"] =
"## 오늘의 가족 집밥",
1967 [
"만드는 법"] =
"## 만드는 법",
1968 [
"재료: 어른 2~3인분"] =
"### 재료: 어른 2~3인분",
1969 [
"재료: 어른 2-3인분"] =
"### 재료: 어른 2~3인분",
1970 [
"손질하기"] =
"### 손질하기",
1971 [
"양념에 재우기"] =
"### 양념에 재우기",
1973 [
"끓이기"] =
"### 끓이기",
1974 [
"조리기"] =
"### 조리기",
1975 [
"부치기"] =
"### 부치기",
1976 [
"초보자 메모"] =
"### 초보자 메모",
1978 [
"파워블로그 한줄평"] =
"## 파워블로그 한줄평",
2018 var lines = content.Replace(
"\r\n",
"\n").Split(
'\n');
2019 for (var i = 0; i < lines.Length; i++)
2021 var trimmed = lines[i].Trim();
2022 if (trimmed.StartsWith(
"#", StringComparison.Ordinal) ||
2023 trimmed.StartsWith(
"<", StringComparison.Ordinal) ||
2024 trimmed.Length == 0)
2027 if (headings.TryGetValue(trimmed, out var heading))
2031 return string.Join(
"\n", lines);
2060 var lines = content.Replace(
"\r\n",
"\n").Split(
'\n').ToList();
2061 for (var i = 0; i < lines.Count; i++)
2064 if (expanded.Count > 0)
2066 if (i > 0 && !
string.IsNullOrWhiteSpace(lines[i - 1]) && expanded[0].Length > 0)
2067 expanded.Insert(0,
"");
2068 if (i + 1 < lines.Count && !
string.IsNullOrWhiteSpace(lines[i + 1]) && expanded[^1].Length > 0)
2071 lines.InsertRange(i, expanded);
2072 i += expanded.Count - 1;
2077 if (cols.Count < 3 || !cols.Any(c => c.Contains(
"비용", StringComparison.OrdinalIgnoreCase)))
continue;
2079 var rows =
new List<List<string>> { cols };
2081 while (j < lines.Count)
2084 if (row.Count != cols.Count)
break;
2089 if (rows.Count < 2)
continue;
2091 var md =
new List<string>
2093 "| " +
string.Join(
" | ", rows[0]) +
" |",
2094 "| " +
string.Join(
" | ", rows[0].Select((_, idx) => idx == 1 ?
"---:" :
"---")) +
" |"
2096 md.AddRange(rows.Skip(1).Select(row =>
"| " +
string.Join(
" | ", row) +
" |"));
2097 lines.RemoveRange(i, rows.Count);
2098 lines.InsertRange(i, md);
2102 return string.Join(
"\n", lines);
2131 var text = Regex.Replace(
2132 content.Replace(
"\r\n",
"\n"),
2135 RegexOptions.IgnoreCase);
2136 var lines = text.Split(
'\n');
2137 var normalized =
new List<string>();
2138 var needsBlankBeforeNextContent =
false;
2140 foreach (var raw
in lines)
2142 var line = raw.TrimEnd();
2143 var isImage = line.TrimStart().StartsWith(
"<img", StringComparison.OrdinalIgnoreCase);
2148 normalized.Add(line.Trim());
2149 needsBlankBeforeNextContent =
true;
2153 if (needsBlankBeforeNextContent && line.Trim().Length > 0)
2156 needsBlankBeforeNextContent =
false;
2158 else if (line.Trim().Length == 0)
2160 needsBlankBeforeNextContent =
false;
2163 normalized.Add(line);
2166 return string.Join(
"\n", normalized).Trim();
2187 if (lines.Count > 0 && lines[^1].Trim().Length > 0)
2217 var trimmed = line.Trim();
2218 if (!trimmed.Contains(
'|') || !trimmed.Contains(
"---", StringComparison.Ordinal))
2222 .Split(
'|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
2223 .Where(c => !
string.IsNullOrWhiteSpace(c))
2226 var firstSeparator = cells.FindIndex(c => c.TrimStart().StartsWith(
"---", StringComparison.Ordinal));
2227 if (firstSeparator < 2)
return [];
2229 var columnCount = firstSeparator;
2230 if (columnCount < 2 || cells.Count < columnCount * 3)
2233 var separator = cells.Skip(columnCount).Take(columnCount).ToList();
2234 if (separator.Count != columnCount || separator.Any(c => !c.TrimStart().StartsWith(
"---", StringComparison.Ordinal)))
2237 var header = cells.Take(columnCount).Select(c => c.Trim()).ToArray();
2238 var values = cells.Skip(columnCount * 2).Select(c => c.Trim()).ToList();
2239 var dataRows = values
2241 .Where(row => row.Length == columnCount)
2242 .Select(row => row.Select(c => c.Trim()).ToArray())
2245 if (dataRows.Count == 0)
2248 var result =
new List<string>
2250 "| " +
string.Join(
" | ", header) +
" |",
2251 "| " +
string.Join(
" | ", Enumerable.Range(0, columnCount).Select(idx => idx == 1 ?
"---:" :
"---")) +
" |"
2253 result.AddRange(dataRows.Select(row =>
"| " +
string.Join(
" | ", row) +
" |"));
2283 ? line.Split(
'\t', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList()
2284 : Regex.Split(line.Trim(),
@"\s{2,}")
2285 .Where(c => !
string.IsNullOrWhiteSpace(c))
2286 .Select(c => c.Trim())
2315 var lines = content.Replace(
"\r\n",
"\n").Split(
'\n');
2316 var inIngredients =
false;
2317 var builder =
new StringBuilder();
2319 foreach (var raw
in lines)
2322 var trimmed = line.Trim();
2324 if (trimmed.StartsWith(
"### 재료:", StringComparison.Ordinal))
2326 inIngredients =
true;
2327 builder.AppendLine(line);
2331 if (inIngredients && trimmed.StartsWith(
"### ", StringComparison.Ordinal))
2332 inIngredients =
false;
2334 if (inIngredients &&
2335 trimmed.Length > 0 &&
2336 !trimmed.StartsWith(
"-", StringComparison.Ordinal) &&
2337 Regex.IsMatch(trimmed,
@"\d+(\.\d+)?\s*(g|kg|ml|l|개|큰술|작은술|컵|cm)\b", RegexOptions.IgnoreCase))
2339 line =
"- " + trimmed;
2342 builder.AppendLine(line);
2345 return builder.ToString().TrimEnd();
2356 private static readonly HttpClient
_http =
new() { Timeout = TimeSpan.FromSeconds(15) };
2369 "upload.wikimedia.org",
2370 "images.unsplash.com",
2371 "images.pexels.com",
2373 "tong.visitkorea.or.kr",
2374 "korean.visitkorea.or.kr",
2375 "cdn.visitkorea.or.kr",
2404 if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
2406 if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
2409 var value = url.Trim();
2410 if (value.Contains(
"...", StringComparison.Ordinal) ||
2411 value.Contains(
"{", StringComparison.Ordinal) ||
2412 value.Contains(
"}", StringComparison.Ordinal) ||
2413 value.Contains(
"[", StringComparison.Ordinal) ||
2414 value.Contains(
"]", StringComparison.Ordinal))
2449 var path = uri.AbsolutePath;
2450 if (Regex.IsMatch(path,
@"\.(jpe?g|png|webp|gif|bmp)(/)?$", RegexOptions.IgnoreCase))
2453 var host = uri.Host.ToLowerInvariant();
2454 return host.Contains(
"upload.wikimedia.org", StringComparison.Ordinal) ||
2455 host.Contains(
"images.unsplash.com", StringComparison.Ordinal) ||
2456 host.Contains(
"images.pexels.com", StringComparison.Ordinal) ||
2457 host.Contains(
"cdn.imweb.me", StringComparison.Ordinal);
2500 private static async Task<List<string>>
ValidateImageUrlsAsync(List<string> urls, Action<string>? onStatus =
null, ISet<string>? excludeUrls =
null)
2502 var valid =
new List<string>();
2503 foreach (var url
in urls)
2507 if (excludeUrls !=
null && excludeUrls.Contains(url))
2513 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
2515 var host = uri.Host.ToLowerInvariant();
2516 var path = uri.AbsolutePath;
2517 bool trustedHost =
_trustedImageHosts.Any(h => host.Contains(h, StringComparison.Ordinal));
2518 bool imageExt = Regex.IsMatch(path,
@"\.(jpe?g|png|webp|gif|bmp)$", RegexOptions.IgnoreCase);
2519 if (trustedHost && imageExt)
2526 onStatus?.Invoke($
"🔍 {url[..Math.Min(60, url.Length)]}");
2574 using var req =
new HttpRequestMessage(method, url);
2575 req.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
2576 using var res = await
_http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
2577 if (!res.IsSuccessStatusCode)
return false;
2579 var ct = res.Content.Headers.ContentType?.MediaType;
2580 return ct ==
null || ct.StartsWith(
"image/", StringComparison.OrdinalIgnoreCase);
2614 {앨범} 주제로 한국 가족 나들이·여행 블로그 포스트 1개를 작성해줘.
2615 아래 형식 그대로만 출력해줘. ===섹션=== 구분자는 반드시 유지하고, 그 외 설명은 쓰지 마.
2616 코드블록(```), JSON, 주석, 머리말, 맺음말을 절대 쓰지 마.
2619 - 이 응답은 Families.Web App_Data/Family/{선택슬러그}/posts/*.json 으로 파싱 저장된다.
2620 - 섹션 태그는 정확히 ===TITLE===, ===CONTENT===, ===IMAGES===, ===VIDEO=== 만 사용한다.
2621 - 모든 [대괄호 안내문]은 실제 값으로 바꾼다. 대괄호 안내문을 그대로 남기지 마.
2622 - TITLE은 파일 목록에 보이는 글 제목이므로 한 줄만 작성한다.
2623 - CONTENT는 Markdown 본문이며, 앱이 그대로 렌더링한다.
2626 - 실제 국내 장소 1곳을 정한다 (실내·실외 모두 가능).
2627 - 정보는 실제 공식 사이트나 현장 기준으로 구체적으로 적는다.
2628 - 확인이 불가한 정보는 "방문 전 공식 확인 필요
"로 표시한다.
2629 - 아이 동반 가족 시선에서 실용적으로 쓴다 (유모차, 수유실, 주차, 대기 등).
2630 - 비용 표는 성인 2명 + 아이 1명 기준으로 작성한다.
2631 - 여행 경비 예상 표는 반드시 Markdown 표로 출력한다. 표 전체를 한 줄에 붙여 쓰지 말고, 헤더/구분선/각 항목 행을 각각 줄바꿈한다.
2634 - IMAGES 섹션에 실제 접근 가능한 이미지 URL을 6개 출력한다. "없음
" 금지. (많이 줄수록 좋음 — 검증 실패 대비 여유분)
2635 - 각 URL은 http 또는 https로 시작하는 이미지 직접 URL이어야 한다.
2636 - URL 줄에는 설명, 번호, 마크다운 이미지 문법을 붙이지 말고 URL만 쓴다.
2637 - 실제 브라우저에서 열리는 직접 이미지 URL만 사용한다. 엑박이 뜰 수 있는 URL은 쓰지 않는다.
2638 - URL은 가능하면 .jpg, .jpeg, .png, .webp 로 끝나는 주소를 고른다.
2639 - URL에 공백, 한글, 괄호, 따옴표가 있으면 반드시 퍼센트 인코딩된 정상 URL로 출력한다.
2640 - 블로그 글 페이지, 검색 결과 페이지, redirect URL, 썸네일 페이지 URL은 금지한다.
2641 - 가로 1000px 이상 선명한 대표 이미지를 고른다. 작은 썸네일, 흐릿한 사진, 야간 저화질 사진은 금지.
2642 - 해당 장소 공식 사이트의 큰 이미지 또는 장소 안내 이미지/지도/전시 이미지를 1순위로 사용한다.
2643 - 공식 이미지가 작거나 흐리면 Wikimedia, 공공기관 보도자료, Unsplash/Pexels의 실제 큰 이미지로 대체한다.
2644 - 이미지 URL은 반드시 해당 장소 자체를 보여줘야 한다. 장소와 무관한 교통수단, 음식, 사람, 일반 풍경 사진은 금지한다.
2645 - Wikimedia를 쓸 때는 upload.wikimedia.org의 실제 파일 URL을 사용한다. commons.wikimedia.org/wiki/... 페이지 URL은 금지한다.
2646 - Unsplash/Pexels를 쓸 때도 실제 검색으로 확인된 이미지 URL만 쓴다. photo-{실제ID}, {id}, ... 같은 템플릿 URL은 절대 쓰지 않는다.
2647 - CONTENT 안 <img> 태그 src에도 같은 URL을 그대로 사용한다.
2651 - YouTube를 넣어야 한다면 embed 재생이 허용되는 공식 채널/공공기관 영상 1개만 사용한다.
2652 - 개인 방문 영상, Shorts, 광고성 영상, iframe 재생 차단 가능성이 큰 영상은 쓰지 말고 "없음
"만 쓴다.
2654 {{(existingTitles.Count > 0 ? $"이미 작성된 장소 목록 (반드시 제외, 절대 반복 금지):\n{string.Join(
"\n", existingTitles.TakeLast(80).Select(t => $
"- {t}"))}\n
" : "")}}
2656 실제 장소명, 아이 동반 핵심 팁 또는 주의사항
2662 이 장소가 아이 동반 가족에게 좋은 이유를 2~3줄로 작성한다.
2663 이동 동선, 체류 시간, 날씨 조건 등 실용 정보 위주로 쓴다.
2665 <img class="fa-inline-photo
" src="[IMAGES 첫 번째 URL]
" alt="[장소 대표 이미지 설명]
">
2669 아이 연령대, 이동 수단, 부모 성향 기준으로 추천 대상을 구체적으로 작성한다.
2673 아이가 지루하지 않을 요소, 실제 체험 동선, 부모가 편했던 포인트를 작성한다.
2674 유모차 이용 여부, 수유실/화장실 위치, 그늘/쉬는 공간도 포함한다.
2676 <img class="fa-inline-photo
" src="[IMAGES 두 번째 URL]
" alt="[장소 내부 또는 체험 이미지 설명]
">
2680 도착부터 출발까지 시간 흐름에 맞게 이동 순서를 작성한다.
2681 구체적인 시간대(예: 11시 도착, 12시 점심)와 이유를 함께 쓴다.
2686 | --- | ---: | --- |
2687 | 입장료 | [실제 금액 또는 무료] | 성인/아이 구분 |
2688 | 주차 | [실제 금액] | 시간당 기준 |
2689 | 식사 | [실제 금액] | 가족 3인 기준 |
2690 | 간식 | [실제 금액] | 선택 항목 |
2691 | 합계 | [실제 금액] | 주차 할인 전 기준 |
2693 <img class="fa-inline-photo
" src="[IMAGES 세 번째 URL]
" alt="[주변 풍경 또는 식사 관련 이미지 설명]
">
2697 주차 위치와 요금, 화장실과 수유실 위치, 유모차 반입/대여 여부를 구체적으로 쓴다.
2701 챙기면 좋은 것, 주의해야 할 것을 항목별로 작성한다.
2705 운영시간, 예약 여부, 계절적 제한, 바뀔 수 있는 정보를 명시한다.
2709 가족 나들이 장소로서 솔직한 평가를 2~3줄로 작성한다. 추천 대상과 비추 상황도 포함한다.
2774 var nextNumber = timeline.LastNumber > 0 ? timeline.LastNumber + 1 : 1;
2777 - 마지막 생성물 이후에만 이어서 작성한다.
2778 - 이번 DETAIL 번호는 반드시 {nextNumber}로 쓴다.
2779 - 이번 식사구분은 반드시 {GetCookingMealName(nextAt)}로 쓴다.
2780 - 이번 날짜는 반드시 {nextAt:yyyy년 M월 d일}로 쓴다.
2781 - TITLE, DATE, CONTENT의 식사구분과 날짜가 위 조건과 어긋나면 안 된다.
2817 public static string BuildCookingPrompt(IReadOnlyList<string> existingTitles, (DateTime? LastPostedAt,
int LastNumber) timeline) =>
2819 {앨범} 주제로 1983년 한국 가족 집밥·육아 기록 블로그 포스트를 1개 작성해줘.
2820 아래 형식 그대로만 출력해줘. ===섹션=== 구분자는 반드시 유지하고, 다른 설명은 쓰지 마.
2821 코드블록(```), JSON, 주석, 머리말, 맺음말을 절대 쓰지 마.
2824 - 이 응답은 Families.Web App_Data/Family/{선택슬러그}/posts/*.json 으로 파싱 저장된다.
2825 - AutoWriter는 ===IMAGES===의 URL 1개와 ===DETAIL_NNN_TITLE===, ===DETAIL_NNN_DATE===, ===DETAIL_NNN_CONTENT=== 섹션만 실제 포스트로 저장한다.
2826 - DETAIL 번호 NNN은 숫자만 사용한다. 예: 231, 232, 233.
2827 - 이미 작성된 제목 목록에 있는 번호(#숫자), 식사구분, 메뉴명, 비슷한 메뉴는 절대 다시 쓰지 않는다.
2828 - 기존 제목에 "#220
"이 있으면 새 제목은 #220을 절대 쓰지 말고, 타임라인 강제 조건의 새 번호만 사용한다.
2829 - 기존 메뉴가 "생선구이
"면 고등어구이, 갈치구이, 조기구이처럼 거의 같은 생선구이류도 피한다.
2830 - 기존 메뉴가 "김밥
"이면 주먹밥, 유부초밥처럼 같은 도시락/밥말이류 반복도 피한다.
2831 - ALBUMS, TIMELINE, 표 형태 요약은 출력하지 마. 저장되지 않으므로 금지.
2832 - 모든 [대괄호 안내문]은 실제 값으로 바꾼다. 대괄호 안내문을 그대로 남기지 마.
2833 - TITLE은 파일 목록에 보이는 글 제목이므로 한 줄만 작성한다.
2834 - CONTENT는 Markdown 본문이며, 앱이 그대로 렌더링한다.
2835 - CONTENT 안의 제목은 반드시 ##, ###, -, 1. 마크다운 문법을 유지한다.
2838 - 아기 시점("저는 아직 수유만 해요
"), 따뜻하고 구체적인 1983년 가정집 분위기
2839 - 할머니, 아버지, 어머니가 등장하고 각자의 행동과 대화를 자연스럽게 묘사한다
2840 - 도입부는 그날의 계절·날씨·집 분위기로 시작한다 (첫 문단, 섹션 헤더 없이)
2843 - 아침/점심/저녁 중 하나를 고르고, TITLE과 본문 전체에서 같은 식사구분을 유지한다.
2844 - 1983년 한국 집밥 제철 음식으로 쓴다.
2845 - 날짜는 1983년 중 자유롭게 (매 실행마다 다르게)
2846 - 아기에게 직접 먹이는 음식인지 어른 밥상인지 반드시 구분
2847 - TITLE의 식사구분(아침/점심/저녁), 메뉴명, 본문 음식, 이미지 음식은 반드시 서로 일치한다.
2848 - 이번 메뉴는 이미 작성된 메뉴 목록과 겹치지 않는 새 메뉴여야 한다.
2849 - 같은 주재료 반복을 피한다. 최근 목록에 생선 메뉴가 많으면 두부/나물/국/찌개/전/조림/볶음 등 다른 계열로 바꾼다.
2851 레시피 작성 기준 (초보자가 실제로 따라 만들 수 있어야 함):
2852 - 재료는 g, ml, cm 단위로 정확히
2853 - 불 세기(약불/중불/강불)와 조리 시간(분 단위)을 명시
2854 - 익었는지 확인하는 기준을 눈으로 보이는 변화로 설명
2855 - "약간
", "적당히
", "노릇하게
"만 쓰지 말고 반드시 기준 수치를 함께 적는다
2856 - 간이 맞지 않을 때, 탈 것 같을 때 복구 방법 포함
2859 - IMAGES 섹션에 메뉴 음식 사진 URL을 6개 출력한다. (검증 실패 대비 여유분)
2860 - 각 URL은 http 또는 https로 시작하는 이미지 직접 URL이어야 한다.
2861 - URL 줄에는 설명, 번호, 마크다운 이미지 문법을 붙이지 말고 URL만 쓴다.
2862 - 메뉴와 다른 음식 사진 금지. 예: 고등어조림 글에 샐러드/덮밥/피자 사진 금지.
2863 - 가로 1000px 이상 선명한 음식 사진만 사용한다. 작은 썸네일, 흐릿한 사진, 생성 이미지 느낌의 사진 금지.
2864 - 실제 브라우저에서 열리는 직접 이미지 URL만 사용한다. 엑박이 뜰 수 있는 URL은 쓰지 않는다.
2865 - URL은 가능하면 .jpg, .jpeg, .png, .webp 로 끝나는 주소를 고른다.
2866 - 우선순위는 upload.wikimedia.org, images.unsplash.com, images.pexels.com 같은 안정적인 이미지 CDN이다.
2867 - 블로그 글 페이지, 검색 결과 페이지, commons.wikimedia.org/wiki/... 페이지, redirect URL, 썸네일 페이지 URL은 금지한다.
2868 - URL에 공백, 한글, 괄호, 따옴표가 있으면 반드시 퍼센트 인코딩된 정상 URL로 출력한다.
2869 - Unsplash/Pexels/Wikimedia/공공 이미지 중 실제 존재하는 직접 이미지 URL만 사용. 가짜 URL 절대 금지.
2870 - 메뉴 사진을 확실히 찾기 어렵다면 그 메뉴를 쓰지 말고, 사진을 확실히 찾을 수 있는 한국 집밥 메뉴로 바꾼다.
2871 - Wikimedia를 쓸 때는 upload.wikimedia.org의 실제 파일 URL을 사용한다. commons.wikimedia.org/wiki/... 페이지 URL은 금지한다.
2872 - Unsplash/Pexels를 쓸 때도 실제 검색으로 확인된 이미지 URL만 쓴다. photo-{실제ID}, {id}, ... 같은 템플릿 URL은 절대 쓰지 않는다.
2873 - DETAIL CONTENT 안에는 <img> 태그를 넣지 마. 이미지는 AutoWriter가 글 상단 미디어로 따로 표시한다.
2875 유튜브: 출력하지 마. VIDEO 섹션에는 항상 "없음
"만 쓴다.
2877 {{(existingTitles.Count > 0 ? $"이미 작성된 메뉴 목록 (반드시 제외, 절대 반복 금지):\n{string.Join(
"\n", existingTitles.TakeLast(120).Select(t => $
"- {t}"))}\n
" : "")}}
2878 {{BuildCookingTimelineInstruction(timeline)}}
2880 ===DETAIL_[새숫자]_TITLE===
2881 [1983 집밥육아 #새숫자] [아침/점심/저녁] [메뉴명]과 [아기 상태 한 줄]
2883 ===DETAIL_[새숫자]_DATE===
2886 ===DETAIL_[새숫자]_CONTENT===
2887 [그날의 집 분위기·계절·가족 동선을 자연스럽게 묘사하는 도입 2~3문장. 헤더 없이.]
2891 [아기 기록. 수유 또는 이유식 상태를 명확히. 어른 반찬을 직접 먹지 않았음을 명시. 2~3문단.]
2895 [가족 식사 분위기와 음식 이야기. 할머니·아버지·어머니 각자의 모습 묘사. 2~3문단.]
2901 - [재료명] [정확한 수량 g/ml/개]
2908 3. [초보자가 실수하기 쉬운 부분]
2910 ### [조리 방법에 맞는 제목 (볶기/끓이기/부치기 등)]
2912 1. [팬/냄비 예열 시간과 불 세기]
2913 2. [재료 넣는 순서, 불 세기, 시간]
2914 3. [익었는지 확인하는 눈에 보이는 기준]
2917 [간이 짤 때/싱거울 때/탈 것 같을 때 복구 방법을 자연스럽게 이어 쓴다. 헤더 없이 1~2문장.]
2925 [한 문장. 그날의 분위기가 담긴 문장.]
2954 foreach (var c
in new[]
2956 @"D:\Work\Dreamine.MVVM.FullKit\20_SOURCES\000. Project\010. App\Families.Web\bin\Release\net8.0-windows\publish\App_Data",
2957 @"D:\Work\Dreamine.MVVM.FullKit\20_SOURCES\000. Project\010. App\Families.Web\bin\Debug\net8.0-windows\App_Data",
2958 Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
@"..\..\..\..\Families.Web\bin\Release\net8.0-windows\publish\App_Data"),
2959 Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
@"..\..\..\..\Families.Web\bin\Debug\net8.0-windows\App_Data"),
2962 try { var f = Path.GetFullPath(c);
if (Directory.Exists(Path.Combine(f,
"Family")))
return f; }
record WaitOption(int Seconds, string Label)
record IntervalOption(int Minutes, string Label)
static string BuildInjectScript(string prompt)
static string BuildExtractScript()
static string UnwrapRaw(string? raw)
ObservableCollection< string > Slugs
WriterSession(string? initialPrompt=null)
static bool IsPlausibleImageUrl(string url)
static DateTime GetNextCookingSlot(DateTime? lastPostedAt)
readonly System.Windows.Threading.Dispatcher _uiDispatcher
static string DetectDefaultRoot()
readonly PromptHistoryService _history
static async Task< bool > IsReachableImageAsync(string url, HttpMethod method)
static int ExtractCookingNumberFromTitle(string title)
static string NormalizeCookingTitle(string title, int number, string mealName)
static List< string > ExtractImageUrls(string content)
static string NormalizeTravelContent(string content)
static string GetSection(string text, string tag)
async Task< List< PostEntry > > ParseCookingResponseAsync(string? raw)
Task SetStatus(string msg)
static readonly HttpClient _http
static readonly string[] _trustedImageHosts
static string NormalizeIngredientLines(string content)
static string BuildTravelPrompt(IReadOnlyList< string > existingTitles)
static WaitOption[] WaitOptions
static async Task< List< string > > ValidateImageUrlsAsync(List< string > urls, Action< string >? onStatus=null, ISet< string >? excludeUrls=null)
const int ExtractionRetryMaxAttempts
Action< string >? NavigateTab
void ClearPromptHistory()
static List< string > SplitTableLine(string line)
static string RemoveInvalidInlineImages(string content, IReadOnlyCollection< string > allowedUrls)
readonly PostWriterService _writer
record AiPostResult(string Title, string Content, List< string > Photos, List< string > Videos)
bool _albumRotationEnabled
async Task RunLoopCycleAsync(CancellationToken ct)
async Task SendPromptNowAsync()
static List< string > TryExpandCollapsedPipeTable(string line)
static bool IsAllowedVideoUrl(string url)
static string NormalizeCookingContent(string content)
const int ExtractionRetryDelaySeconds
async Task LoopWorkerAsync(CancellationToken ct)
static string GetCookingMealName(DateTime postedAt)
IntervalOption SelectedInterval
Func< string, Task< string?> >? ExecuteScriptAsync
CancellationTokenSource? _loopCts
readonly HashSet< string > _usedImageUrls
static int[] NewChatOptions
static IntervalOption[] IntervalOptions
async Task ExtractAndSaveAsync()
static string NormalizeKnownHeadings(string content, IReadOnlyDictionary< string, string > headings)
static bool IsLikelyDirectImageUrl(string url)
static string BuildCookingTimelineInstruction((DateTime? LastPostedAt, int LastNumber) timeline)
IntervalOption _selectedInterval
static ? AiPostResult ParseTravelResponse(string? raw)
MediaPosition _mediaPosition
static string NormalizeSimpleTables(string content)
ObservableCollection< AlbumInfo > Albums
static string BuildCookingPrompt(IReadOnlyList< string > existingTitles)
static string NormalizeImageBlockSpacing(string content)
static List< string > MergeUrls(IEnumerable< string > primary, IEnumerable< string > secondary)
static string SubstituteImages(string content, IList< string > urls, int offset=0)
void RefreshPromptWithExisting()
AlbumInfo? _selectedAlbum
static void AddBlankLineIfNeeded(List< string > lines)