koishi-plugin-my-pig-group-friends 1.1.1 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -25
- package/lib/config.d.ts +6 -1
- package/lib/database.d.ts +3 -0
- package/lib/index.js +908 -43
- package/lib/services/pexels.d.ts +7 -0
- package/lib/services/summary.d.ts +38 -0
- package/lib/services/unsplash.d.ts +2 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -45,7 +45,10 @@ function applyDatabase(ctx) {
|
|
|
45
45
|
platform: "string",
|
|
46
46
|
timestamp: "timestamp",
|
|
47
47
|
country: "string",
|
|
48
|
+
countryZh: "string",
|
|
48
49
|
location: "string",
|
|
50
|
+
locationZh: "string",
|
|
51
|
+
timezone: "string",
|
|
49
52
|
imagePath: "string",
|
|
50
53
|
isAIGC: "boolean"
|
|
51
54
|
}, { primary: "id", autoInc: true });
|
|
@@ -101,6 +104,13 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
101
104
|
return "image/jpeg";
|
|
102
105
|
}, "sniffMime");
|
|
103
106
|
const inlineMaxBytes = 900 * 1024;
|
|
107
|
+
const fetchTimeoutMs = config.backgroundFetchTimeoutMs ?? 8e3;
|
|
108
|
+
const shouldServerFetch = /* @__PURE__ */ __name((url) => {
|
|
109
|
+
const mode = config.backgroundFetchMode || "auto";
|
|
110
|
+
if (mode === "never") return false;
|
|
111
|
+
if (mode === "always") return true;
|
|
112
|
+
return !/^https?:\/\/(images|source)\.unsplash\.com\//i.test(url);
|
|
113
|
+
}, "shouldServerFetch");
|
|
104
114
|
const fetchToDataUrl = /* @__PURE__ */ __name(async (url) => {
|
|
105
115
|
const normalized = normalizeImageUrl(url);
|
|
106
116
|
if (config.debug) ctx.logger("pig").debug(`Normalized background URL: ${normalized}`);
|
|
@@ -122,20 +132,25 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
if (/^https?:\/\//i.test(normalized)) {
|
|
135
|
+
if (!shouldServerFetch(normalized)) {
|
|
136
|
+
if (config.debug) {
|
|
137
|
+
ctx.logger("pig").debug(`Skip server-side fetch for background: ${normalized}`);
|
|
138
|
+
}
|
|
139
|
+
return normalized;
|
|
140
|
+
}
|
|
125
141
|
try {
|
|
126
142
|
if (config.debug) ctx.logger("pig").debug(`Server-side fetching background: ${normalized}`);
|
|
127
143
|
const response = await ctx.http(normalized, {
|
|
128
144
|
responseType: "arraybuffer",
|
|
129
|
-
timeout:
|
|
130
|
-
// Reduced from 15s to 8s for better responsiveness
|
|
145
|
+
timeout: fetchTimeoutMs,
|
|
131
146
|
headers: {
|
|
132
147
|
"User-Agent": "Mozilla/5.0",
|
|
133
148
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"
|
|
134
149
|
},
|
|
135
150
|
redirect: "follow"
|
|
136
151
|
});
|
|
137
|
-
const contentType = response.headers
|
|
138
|
-
const contentLength = response.headers
|
|
152
|
+
const contentType = response.headers?.["content-type"] || "";
|
|
153
|
+
const contentLength = response.headers?.["content-length"] || "unknown";
|
|
139
154
|
if (config.debug) {
|
|
140
155
|
ctx.logger("pig").debug(
|
|
141
156
|
`Background fetch response: status=${response.status} url=${response.url} content-type=${contentType || "unknown"} content-length=${contentLength}`
|
|
@@ -177,7 +192,7 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
177
192
|
}
|
|
178
193
|
const now = /* @__PURE__ */ new Date();
|
|
179
194
|
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`;
|
|
180
|
-
const
|
|
195
|
+
const bgCssValue = bgImage ? `url("${bgImage}")` : "none";
|
|
181
196
|
const html = `
|
|
182
197
|
<!DOCTYPE html>
|
|
183
198
|
<html>
|
|
@@ -195,12 +210,18 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
195
210
|
width: 1080px;
|
|
196
211
|
height: 1920px;
|
|
197
212
|
overflow: hidden;
|
|
198
|
-
/*
|
|
199
|
-
font-family:
|
|
213
|
+
/* Default emoji font set to Noto Color Emoji to maintain layout consistency */
|
|
214
|
+
font-family: "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei", "WenQuanYi Micro Hei", "Droid Sans Fallback", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji";
|
|
200
215
|
background: #f0f0f2;
|
|
201
|
-
--bg-image:
|
|
216
|
+
--bg-image: ${bgCssValue};
|
|
202
217
|
}
|
|
203
218
|
|
|
219
|
+
/* Specific class for the pig emoji to use Twemoji */
|
|
220
|
+
.twemoji {
|
|
221
|
+
font-family: "Twemoji", "Noto Color Emoji", sans-serif;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
204
225
|
.wrapper {
|
|
205
226
|
position: relative;
|
|
206
227
|
width: 100%;
|
|
@@ -215,10 +236,9 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
215
236
|
inset: 0;
|
|
216
237
|
width: 100%;
|
|
217
238
|
height: 100%;
|
|
239
|
+
display: block;
|
|
240
|
+
object-fit: cover;
|
|
218
241
|
background-color: #d1d1d6;
|
|
219
|
-
background-image: var(--bg-image);
|
|
220
|
-
background-size: cover;
|
|
221
|
-
background-position: center;
|
|
222
242
|
z-index: 0;
|
|
223
243
|
}
|
|
224
244
|
|
|
@@ -376,7 +396,7 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
376
396
|
.message-text {
|
|
377
397
|
font-size: 56px;
|
|
378
398
|
line-height: 1.25;
|
|
379
|
-
font-weight:
|
|
399
|
+
font-weight: 900;
|
|
380
400
|
color: #1d1d1f;
|
|
381
401
|
letter-spacing: -0.03em;
|
|
382
402
|
word-break: break-word;
|
|
@@ -390,8 +410,11 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
390
410
|
box-decoration-break: clone;
|
|
391
411
|
-webkit-box-decoration-break: clone;
|
|
392
412
|
display: inline-block;
|
|
413
|
+
font-weight: 900;
|
|
414
|
+
letter-spacing: -0.01em;
|
|
393
415
|
}
|
|
394
416
|
|
|
417
|
+
|
|
395
418
|
.divider {
|
|
396
419
|
height: 2px;
|
|
397
420
|
background: rgba(0,0,0,0.08);
|
|
@@ -424,14 +447,14 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
424
447
|
|
|
425
448
|
.location-pill span {
|
|
426
449
|
font-size: 26px;
|
|
427
|
-
font-weight:
|
|
450
|
+
font-weight: 800;
|
|
428
451
|
color: #ffffff;
|
|
429
452
|
letter-spacing: 0.02em;
|
|
430
453
|
}
|
|
431
454
|
|
|
432
455
|
.landmark-name {
|
|
433
456
|
font-size: 38px;
|
|
434
|
-
font-weight:
|
|
457
|
+
font-weight: 900;
|
|
435
458
|
color: #1d1d1f;
|
|
436
459
|
letter-spacing: -0.01em;
|
|
437
460
|
padding-left: 6px;
|
|
@@ -463,7 +486,7 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
463
486
|
</head>
|
|
464
487
|
<body>
|
|
465
488
|
<div class="wrapper">
|
|
466
|
-
<
|
|
489
|
+
<img class="bg-image" src="${bgImage || ""}" alt="" />
|
|
467
490
|
<div class="bg-overlay"></div>
|
|
468
491
|
|
|
469
492
|
<div class="card-container">
|
|
@@ -482,7 +505,7 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
482
505
|
|
|
483
506
|
<div class="message-body">
|
|
484
507
|
<div class="message-text">
|
|
485
|
-
今天
|
|
508
|
+
今天 <span class="twemoji">🐷</span>猪醒在<br/>
|
|
486
509
|
<span class="highlight">${data.location.landmarkZh || data.location.landmark}</span>
|
|
487
510
|
</div>
|
|
488
511
|
</div>
|
|
@@ -498,7 +521,7 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
|
|
|
498
521
|
</div>
|
|
499
522
|
|
|
500
523
|
<div class="brand-tag">
|
|
501
|
-
<div class="brand-icon">🐷</div>
|
|
524
|
+
<div class="brand-icon"><span class="twemoji">🐷</span></div>
|
|
502
525
|
<div class="brand-name">Pig Travel</div>
|
|
503
526
|
</div>
|
|
504
527
|
</div>
|
|
@@ -1037,7 +1060,8 @@ async function searchUnsplashPhoto(ctx, accessKey, query, debug = false) {
|
|
|
1037
1060
|
{
|
|
1038
1061
|
params: {
|
|
1039
1062
|
query,
|
|
1040
|
-
per_page:
|
|
1063
|
+
per_page: 10,
|
|
1064
|
+
// Fetch more results for variety
|
|
1041
1065
|
orientation: "landscape"
|
|
1042
1066
|
},
|
|
1043
1067
|
headers: {
|
|
@@ -1048,13 +1072,15 @@ async function searchUnsplashPhoto(ctx, accessKey, query, debug = false) {
|
|
|
1048
1072
|
}
|
|
1049
1073
|
);
|
|
1050
1074
|
if (response.results && response.results.length > 0) {
|
|
1051
|
-
|
|
1075
|
+
const randomIndex = Math.floor(Math.random() * response.results.length);
|
|
1076
|
+
const selectedPhoto = response.results[randomIndex];
|
|
1077
|
+
let photoUrl = selectedPhoto.urls.regular;
|
|
1052
1078
|
if (photoUrl.includes("?")) {
|
|
1053
1079
|
photoUrl += "&fm=webp&q=85";
|
|
1054
1080
|
} else {
|
|
1055
1081
|
photoUrl += "?fm=webp&q=85";
|
|
1056
1082
|
}
|
|
1057
|
-
if (debug) ctx.logger("pig").debug(`Unsplash:
|
|
1083
|
+
if (debug) ctx.logger("pig").debug(`Unsplash: Selected photo ${randomIndex + 1}/${response.results.length}, URL: ${photoUrl}`);
|
|
1058
1084
|
return photoUrl;
|
|
1059
1085
|
}
|
|
1060
1086
|
if (debug) ctx.logger("pig").debug("Unsplash: No photos found for query");
|
|
@@ -1066,6 +1092,53 @@ async function searchUnsplashPhoto(ctx, accessKey, query, debug = false) {
|
|
|
1066
1092
|
}
|
|
1067
1093
|
__name(searchUnsplashPhoto, "searchUnsplashPhoto");
|
|
1068
1094
|
|
|
1095
|
+
// src/services/pexels.ts
|
|
1096
|
+
var PEXELS_API_BASE = "https://api.pexels.com/v1";
|
|
1097
|
+
async function searchPexelsPhoto(ctx, apiKey, query, debug = false) {
|
|
1098
|
+
if (!apiKey) {
|
|
1099
|
+
if (debug) ctx.logger("pig").debug("Pexels: No API key provided");
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
if (debug) ctx.logger("pig").debug(`Pexels: Searching for "${query}"`);
|
|
1104
|
+
const response = await ctx.http.get(
|
|
1105
|
+
`${PEXELS_API_BASE}/search`,
|
|
1106
|
+
{
|
|
1107
|
+
params: {
|
|
1108
|
+
query,
|
|
1109
|
+
per_page: 10,
|
|
1110
|
+
// Fetch more results for variety
|
|
1111
|
+
orientation: "landscape"
|
|
1112
|
+
},
|
|
1113
|
+
headers: {
|
|
1114
|
+
Authorization: apiKey
|
|
1115
|
+
},
|
|
1116
|
+
timeout: 1e4
|
|
1117
|
+
}
|
|
1118
|
+
);
|
|
1119
|
+
if (response.photos && response.photos.length > 0) {
|
|
1120
|
+
const randomIndex = Math.floor(Math.random() * response.photos.length);
|
|
1121
|
+
const selectedPhoto = response.photos[randomIndex];
|
|
1122
|
+
let photoUrl = selectedPhoto.src.large2x || selectedPhoto.src.large;
|
|
1123
|
+
if (photoUrl.includes("w=")) {
|
|
1124
|
+
photoUrl = photoUrl.replace(/w=\d+/, "w=1080");
|
|
1125
|
+
} else if (photoUrl.includes("?")) {
|
|
1126
|
+
photoUrl += "&w=1080";
|
|
1127
|
+
} else {
|
|
1128
|
+
photoUrl += "?w=1080";
|
|
1129
|
+
}
|
|
1130
|
+
if (debug) ctx.logger("pig").debug(`Pexels: Selected photo ${randomIndex + 1}/${response.photos.length}, URL: ${photoUrl}`);
|
|
1131
|
+
return photoUrl;
|
|
1132
|
+
}
|
|
1133
|
+
if (debug) ctx.logger("pig").debug("Pexels: No photos found for query");
|
|
1134
|
+
return null;
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
ctx.logger("pig").warn(`Pexels API error: ${e}`);
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
__name(searchPexelsPhoto, "searchPexelsPhoto");
|
|
1141
|
+
|
|
1069
1142
|
// src/services/location.ts
|
|
1070
1143
|
var LOCATION_CATEGORIES = [
|
|
1071
1144
|
"自然奇观(峡谷、瀑布、火山、冰川、沙漠绿洲等)",
|
|
@@ -1080,13 +1153,43 @@ var LOCATION_CATEGORIES = [
|
|
|
1080
1153
|
"神秘与奇特之地(地质奇观、UFO小镇、怪异地貌等)"
|
|
1081
1154
|
];
|
|
1082
1155
|
var CONTINENTS = ["亚洲", "欧洲", "非洲", "北美洲", "南美洲", "大洋洲", "南极洲"];
|
|
1156
|
+
function getSunriseTimezoneHint() {
|
|
1157
|
+
const now = /* @__PURE__ */ new Date();
|
|
1158
|
+
const utcHour = now.getUTCHours();
|
|
1159
|
+
const targetLocalHour = 6;
|
|
1160
|
+
const idealOffset = targetLocalHour - utcHour;
|
|
1161
|
+
const normalizeOffset = /* @__PURE__ */ __name((offset) => {
|
|
1162
|
+
if (offset < -12) return offset + 24;
|
|
1163
|
+
if (offset > 14) return offset - 24;
|
|
1164
|
+
return offset;
|
|
1165
|
+
}, "normalizeOffset");
|
|
1166
|
+
const minOffset = normalizeOffset(idealOffset - 1);
|
|
1167
|
+
const maxOffset = normalizeOffset(idealOffset + 1);
|
|
1168
|
+
const getRegionByOffset = /* @__PURE__ */ __name((offset) => {
|
|
1169
|
+
if (offset >= 9 && offset <= 12) return "东亚、澳大利亚东部、太平洋岛屿";
|
|
1170
|
+
if (offset >= 5 && offset <= 8) return "南亚、东南亚、中亚";
|
|
1171
|
+
if (offset >= 2 && offset <= 4) return "中东、东欧、东非";
|
|
1172
|
+
if (offset >= -1 && offset <= 1) return "西欧、西非";
|
|
1173
|
+
if (offset >= -5 && offset <= -2) return "南美洲东部、大西洋";
|
|
1174
|
+
if (offset >= -8 && offset <= -6) return "北美洲西部、太平洋东部";
|
|
1175
|
+
if (offset >= -12 && offset <= -9) return "太平洋中部、夏威夷、阿拉斯加";
|
|
1176
|
+
return "全球各地";
|
|
1177
|
+
}, "getRegionByOffset");
|
|
1178
|
+
const formatOffset = /* @__PURE__ */ __name((offset) => offset >= 0 ? `UTC+${offset}` : `UTC${offset}`, "formatOffset");
|
|
1179
|
+
return {
|
|
1180
|
+
utcOffsetRange: `${formatOffset(minOffset)} 到 ${formatOffset(maxOffset)}`,
|
|
1181
|
+
regionHint: getRegionByOffset(idealOffset)
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
__name(getSunriseTimezoneHint, "getSunriseTimezoneHint");
|
|
1083
1185
|
function getRandomPromptHints() {
|
|
1084
1186
|
const category = LOCATION_CATEGORIES[Math.floor(Math.random() * LOCATION_CATEGORIES.length)];
|
|
1085
1187
|
const continent = CONTINENTS[Math.floor(Math.random() * CONTINENTS.length)];
|
|
1086
1188
|
const hotCountries = ["法国", "日本", "意大利", "美国", "英国", "中国", "西班牙", "泰国", "澳大利亚"];
|
|
1087
1189
|
const shuffled = hotCountries.sort(() => Math.random() - 0.5);
|
|
1088
1190
|
const avoidCountries = shuffled.slice(0, 3 + Math.floor(Math.random() * 4)).join("、");
|
|
1089
|
-
|
|
1191
|
+
const sunriseHint = getSunriseTimezoneHint();
|
|
1192
|
+
return { category, continent, avoidCountries, sunriseHint };
|
|
1090
1193
|
}
|
|
1091
1194
|
__name(getRandomPromptHints, "getRandomPromptHints");
|
|
1092
1195
|
var LOCATION_GENERATION_PROMPT = `你是一位资深旅行探险家,专门发掘世界各地的独特目的地。
|
|
@@ -1121,12 +1224,23 @@ async function generateLocationWithLLM(ctx, config) {
|
|
|
1121
1224
|
return getRandomStaticLocation();
|
|
1122
1225
|
}
|
|
1123
1226
|
const hints = getRandomPromptHints();
|
|
1124
|
-
|
|
1227
|
+
let userPrompt = `请生成一个【${hints.category}】类型的旅游目的地。
|
|
1228
|
+
|
|
1229
|
+
🌅 时区要求(重要):当前 UTC 时间是 ${(/* @__PURE__ */ new Date()).getUTCHours()}:${String((/* @__PURE__ */ new Date()).getUTCMinutes()).padStart(2, "0")},请选择一个正处于日出时段(当地时间约 5:00-7:00)的地点。
|
|
1230
|
+
符合条件的时区范围大约是 ${hints.sunriseHint.utcOffsetRange},对应地区包括:${hints.sunriseHint.regionHint}。
|
|
1125
1231
|
|
|
1126
|
-
|
|
1232
|
+
如果上述地区没有合适的【${hints.category}】类型目的地,可以适当放宽到邻近时区,但优先选择正在迎接日出的地方。
|
|
1233
|
+
|
|
1234
|
+
特别要求:请避开 ${hints.avoidCountries} 这些热门国家,选择一个更独特、更少人知道的地方。要求这个地方在地理位置或文化上具有独特性。`;
|
|
1235
|
+
if (config.llmLocationCustomContext) {
|
|
1236
|
+
userPrompt += `
|
|
1237
|
+
|
|
1238
|
+
此外,请参考以下用户提供的偏好或上下文:${config.llmLocationCustomContext}`;
|
|
1239
|
+
}
|
|
1240
|
+
userPrompt += `
|
|
1127
1241
|
|
|
1128
1242
|
直接输出JSON,不要有任何其他文字。`;
|
|
1129
|
-
if (config.debug) ctx.logger("pig").debug(`Location prompt hints:
|
|
1243
|
+
if (config.debug) ctx.logger("pig").debug(`Location prompt hints: sunrise=${hints.sunriseHint.regionHint}, category=${hints.category}`);
|
|
1130
1244
|
const messages = [
|
|
1131
1245
|
new import_messages.SystemMessage(LOCATION_GENERATION_PROMPT),
|
|
1132
1246
|
new import_messages.HumanMessage(userPrompt)
|
|
@@ -1137,29 +1251,37 @@ async function generateLocationWithLLM(ctx, config) {
|
|
|
1137
1251
|
const location = parseLocationResponse(content);
|
|
1138
1252
|
if (location) {
|
|
1139
1253
|
ctx.logger("pig").info(`LLM generated location: ${location.landmarkZh} (${location.landmark}), ${location.countryZh}`);
|
|
1140
|
-
if (config.unsplashAccessKey) {
|
|
1254
|
+
if (config.unsplashAccessKey || config.pexelsApiKey) {
|
|
1255
|
+
const template = config.imageSearchPrompt || "{landmark} {country} landscape";
|
|
1256
|
+
const formatQuery = /* @__PURE__ */ __name((tmpl) => tmpl.replace("{landmark}", location.landmark).replace("{country}", location.country).replace("{city}", location.city || "").trim(), "formatQuery");
|
|
1141
1257
|
const searchQueries = [
|
|
1258
|
+
formatQuery(template),
|
|
1259
|
+
// Primary: User template
|
|
1142
1260
|
`${location.landmark} ${location.country}`,
|
|
1143
|
-
//
|
|
1261
|
+
// Fallback 1: specific
|
|
1144
1262
|
location.city ? `${location.city} ${location.country}` : null,
|
|
1145
|
-
//
|
|
1146
|
-
`${location.country} landscape`,
|
|
1147
|
-
// Country landscape
|
|
1263
|
+
// Fallback 2: city
|
|
1148
1264
|
location.country
|
|
1149
|
-
//
|
|
1265
|
+
// Fallback 3: country
|
|
1150
1266
|
].filter(Boolean);
|
|
1151
1267
|
let photoUrl = null;
|
|
1152
1268
|
for (const query of searchQueries) {
|
|
1153
|
-
if (config.
|
|
1154
|
-
|
|
1269
|
+
if (config.unsplashAccessKey) {
|
|
1270
|
+
if (config.debug) ctx.logger("pig").debug(`Searching Unsplash for: ${query}`);
|
|
1271
|
+
photoUrl = await searchUnsplashPhoto(ctx, config.unsplashAccessKey, query, config.debug);
|
|
1272
|
+
}
|
|
1273
|
+
if (!photoUrl && config.pexelsApiKey) {
|
|
1274
|
+
if (config.debug) ctx.logger("pig").debug(`Searching Pexels for: ${query}`);
|
|
1275
|
+
photoUrl = await searchPexelsPhoto(ctx, config.pexelsApiKey, query, config.debug);
|
|
1276
|
+
}
|
|
1155
1277
|
if (photoUrl) {
|
|
1156
|
-
|
|
1278
|
+
ctx.logger("pig").info(`Found photo URL: ${photoUrl}`);
|
|
1157
1279
|
location.landscapeUrl = photoUrl;
|
|
1158
1280
|
break;
|
|
1159
1281
|
}
|
|
1160
1282
|
}
|
|
1161
1283
|
if (!photoUrl && config.debug) {
|
|
1162
|
-
ctx.logger("pig").debug("All
|
|
1284
|
+
ctx.logger("pig").debug("All image searches returned no results, using LLM-generated URL fallback");
|
|
1163
1285
|
}
|
|
1164
1286
|
}
|
|
1165
1287
|
return location;
|
|
@@ -1190,7 +1312,8 @@ function parseLocationResponse(content) {
|
|
|
1190
1312
|
landscapeUrl: data.landscapeUrl
|
|
1191
1313
|
};
|
|
1192
1314
|
if (!location.landscapeUrl.startsWith("http")) {
|
|
1193
|
-
|
|
1315
|
+
const query = `${encodeURIComponent(location.landmark)},${encodeURIComponent(location.country)}`;
|
|
1316
|
+
location.landscapeUrl = `https://images.unsplash.com/featured/?${query}`;
|
|
1194
1317
|
}
|
|
1195
1318
|
return location;
|
|
1196
1319
|
} catch (e) {
|
|
@@ -1283,7 +1406,10 @@ async function triggerTravelSequence(ctx, config, userInfo, platform) {
|
|
|
1283
1406
|
platform,
|
|
1284
1407
|
timestamp: now,
|
|
1285
1408
|
country: location.country,
|
|
1409
|
+
countryZh: location.countryZh || location.country,
|
|
1286
1410
|
location: location.landmark,
|
|
1411
|
+
locationZh: location.landmarkZh || location.landmark,
|
|
1412
|
+
timezone: location.timezone || "UTC",
|
|
1287
1413
|
imagePath: imageUrl || "",
|
|
1288
1414
|
// 存储 URL 或空
|
|
1289
1415
|
isAIGC
|
|
@@ -1298,22 +1424,650 @@ async function triggerTravelSequence(ctx, config, userInfo, platform) {
|
|
|
1298
1424
|
}
|
|
1299
1425
|
__name(triggerTravelSequence, "triggerTravelSequence");
|
|
1300
1426
|
|
|
1427
|
+
// src/services/summary.ts
|
|
1428
|
+
async function getMonthlyLogs(ctx, userId, platform, year, month) {
|
|
1429
|
+
const startDate = new Date(year, month - 1, 1);
|
|
1430
|
+
const endDate = new Date(year, month, 1);
|
|
1431
|
+
const logs = await ctx.database.get("pig_travel_log", {
|
|
1432
|
+
userId,
|
|
1433
|
+
platform,
|
|
1434
|
+
timestamp: {
|
|
1435
|
+
$gte: startDate,
|
|
1436
|
+
$lt: endDate
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
1440
|
+
return logs;
|
|
1441
|
+
}
|
|
1442
|
+
__name(getMonthlyLogs, "getMonthlyLogs");
|
|
1443
|
+
async function getUsersWithLogsInMonth(ctx, year, month) {
|
|
1444
|
+
const startDate = new Date(year, month - 1, 1);
|
|
1445
|
+
const endDate = new Date(year, month, 1);
|
|
1446
|
+
const logs = await ctx.database.get("pig_travel_log", {
|
|
1447
|
+
timestamp: {
|
|
1448
|
+
$gte: startDate,
|
|
1449
|
+
$lt: endDate
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
const userMap = /* @__PURE__ */ new Map();
|
|
1453
|
+
for (const log of logs) {
|
|
1454
|
+
const key = `${log.platform}:${log.userId}`;
|
|
1455
|
+
if (!userMap.has(key)) {
|
|
1456
|
+
userMap.set(key, { userId: log.userId, platform: log.platform });
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return Array.from(userMap.values());
|
|
1460
|
+
}
|
|
1461
|
+
__name(getUsersWithLogsInMonth, "getUsersWithLogsInMonth");
|
|
1462
|
+
function formatTimezone(timezone) {
|
|
1463
|
+
if (!timezone || timezone === "UTC") return "UTC";
|
|
1464
|
+
try {
|
|
1465
|
+
const now = /* @__PURE__ */ new Date();
|
|
1466
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
1467
|
+
timeZone: timezone,
|
|
1468
|
+
timeZoneName: "shortOffset"
|
|
1469
|
+
});
|
|
1470
|
+
const parts = formatter.formatToParts(now);
|
|
1471
|
+
const tzPart = parts.find((p) => p.type === "timeZoneName");
|
|
1472
|
+
if (tzPart) {
|
|
1473
|
+
return tzPart.value.replace("GMT", "UTC");
|
|
1474
|
+
}
|
|
1475
|
+
} catch {
|
|
1476
|
+
}
|
|
1477
|
+
if (timezone.startsWith("UTC")) return timezone;
|
|
1478
|
+
return "UTC";
|
|
1479
|
+
}
|
|
1480
|
+
__name(formatTimezone, "formatTimezone");
|
|
1481
|
+
async function generateMonthlySummaryCard(ctx, config, data) {
|
|
1482
|
+
const { year, month, logs, username, totalTrips, countriesVisited, locationsVisited } = data;
|
|
1483
|
+
let { avatarUrl } = data;
|
|
1484
|
+
if (avatarUrl && avatarUrl.startsWith("http")) {
|
|
1485
|
+
try {
|
|
1486
|
+
if (config.debug) ctx.logger("pig").debug(`Fetching avatar: ${avatarUrl}`);
|
|
1487
|
+
const response = await ctx.http(avatarUrl, {
|
|
1488
|
+
responseType: "arraybuffer",
|
|
1489
|
+
timeout: 5e3,
|
|
1490
|
+
headers: {
|
|
1491
|
+
"User-Agent": "Mozilla/5.0",
|
|
1492
|
+
"Accept": "image/*"
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
const buffer = Buffer.from(response.data);
|
|
1496
|
+
const contentType = response.headers?.["content-type"] || "image/jpeg";
|
|
1497
|
+
avatarUrl = `data:${contentType};base64,${buffer.toString("base64")}`;
|
|
1498
|
+
if (config.debug) ctx.logger("pig").debug(`Avatar fetched successfully, size: ${buffer.length}`);
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
if (config.debug) ctx.logger("pig").warn(`Failed to fetch avatar: ${e}`);
|
|
1501
|
+
avatarUrl = "";
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const monthNames = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
|
|
1505
|
+
const monthName = monthNames[month - 1];
|
|
1506
|
+
const tripsHtml = logs.slice(0, 12).map((log, index) => {
|
|
1507
|
+
const date = new Date(log.timestamp);
|
|
1508
|
+
const dayStr = `${date.getMonth() + 1}/${date.getDate()}`;
|
|
1509
|
+
const tz = formatTimezone(log.timezone);
|
|
1510
|
+
return `
|
|
1511
|
+
<div class="trip-item">
|
|
1512
|
+
<div class="trip-index">${index + 1}</div>
|
|
1513
|
+
<div class="trip-info">
|
|
1514
|
+
<div class="trip-location">${escapeHtml2(log.locationZh || log.location)}</div>
|
|
1515
|
+
<div class="trip-country">${escapeHtml2(log.countryZh || log.country)} · ${dayStr}</div>
|
|
1516
|
+
<div class="trip-location-en">${escapeHtml2(log.location)}</div>
|
|
1517
|
+
</div>
|
|
1518
|
+
<div class="trip-tz">${tz}</div>
|
|
1519
|
+
</div>
|
|
1520
|
+
`;
|
|
1521
|
+
}).join("");
|
|
1522
|
+
const moreTripsHtml = logs.length > 12 ? `<div class="more-trips">... 还有 ${logs.length - 12} 次旅行</div>` : "";
|
|
1523
|
+
const statsHtml = `
|
|
1524
|
+
<div class="stats-grid">
|
|
1525
|
+
<div class="stat-item bg-yellow">
|
|
1526
|
+
<div class="stat-value">${totalTrips}</div>
|
|
1527
|
+
<div class="stat-label">次旅行</div>
|
|
1528
|
+
<div class="deco-dot"></div>
|
|
1529
|
+
</div>
|
|
1530
|
+
<div class="stat-item bg-pink">
|
|
1531
|
+
<div class="stat-value">${countriesVisited.length}</div>
|
|
1532
|
+
<div class="stat-label">个国家</div>
|
|
1533
|
+
<div class="deco-line"></div>
|
|
1534
|
+
</div>
|
|
1535
|
+
<div class="stat-item bg-cyan">
|
|
1536
|
+
<div class="stat-value">${locationsVisited.length}</div>
|
|
1537
|
+
<div class="stat-label">个地点</div>
|
|
1538
|
+
<div class="deco-triangle"></div>
|
|
1539
|
+
</div>
|
|
1540
|
+
</div>
|
|
1541
|
+
`;
|
|
1542
|
+
const html = `
|
|
1543
|
+
<!DOCTYPE html>
|
|
1544
|
+
<html>
|
|
1545
|
+
<head>
|
|
1546
|
+
<meta charset="UTF-8">
|
|
1547
|
+
<style>
|
|
1548
|
+
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700;900&display=swap');
|
|
1549
|
+
|
|
1550
|
+
* {
|
|
1551
|
+
margin: 0;
|
|
1552
|
+
padding: 0;
|
|
1553
|
+
box-sizing: border-box;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
body {
|
|
1557
|
+
width: 1080px;
|
|
1558
|
+
min-height: 1920px;
|
|
1559
|
+
font-family: "Noto Sans SC", sans-serif, "Noto Color Emoji";
|
|
1560
|
+
background-color: #F0F0F0;
|
|
1561
|
+
background-image:
|
|
1562
|
+
radial-gradient(#000 10%, transparent 11%),
|
|
1563
|
+
radial-gradient(#000 10%, transparent 11%);
|
|
1564
|
+
background-size: 30px 30px;
|
|
1565
|
+
background-position: 0 0, 15px 15px;
|
|
1566
|
+
background-color: #FFDEE9;
|
|
1567
|
+
padding: 60px;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
.twemoji {
|
|
1571
|
+
font-family: "Twemoji", "Noto Color Emoji", sans-serif;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
.container {
|
|
1575
|
+
background: #fff;
|
|
1576
|
+
border: 4px solid #000;
|
|
1577
|
+
box-shadow: 20px 20px 0 #000;
|
|
1578
|
+
padding: 60px;
|
|
1579
|
+
position: relative;
|
|
1580
|
+
overflow: hidden;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/* Memphis decorative elements */
|
|
1584
|
+
.deco-shape-1 {
|
|
1585
|
+
position: absolute;
|
|
1586
|
+
top: -20px;
|
|
1587
|
+
right: -20px;
|
|
1588
|
+
width: 150px;
|
|
1589
|
+
height: 150px;
|
|
1590
|
+
background: #FFD700;
|
|
1591
|
+
border: 4px solid #000;
|
|
1592
|
+
border-radius: 50%;
|
|
1593
|
+
z-index: 0;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
.deco-shape-2 {
|
|
1597
|
+
position: absolute;
|
|
1598
|
+
bottom: 40px;
|
|
1599
|
+
left: -30px;
|
|
1600
|
+
width: 100px;
|
|
1601
|
+
height: 100px;
|
|
1602
|
+
background: #00CED1;
|
|
1603
|
+
border: 4px solid #000;
|
|
1604
|
+
transform: rotate(45deg);
|
|
1605
|
+
z-index: 0;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
.header {
|
|
1609
|
+
display: flex;
|
|
1610
|
+
align-items: center;
|
|
1611
|
+
gap: 32px;
|
|
1612
|
+
margin-bottom: 48px;
|
|
1613
|
+
position: relative;
|
|
1614
|
+
z-index: 1;
|
|
1615
|
+
background: #fff;
|
|
1616
|
+
border: 4px solid #000;
|
|
1617
|
+
padding: 24px;
|
|
1618
|
+
box-shadow: 8px 8px 0 #000;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
.avatar-ring {
|
|
1622
|
+
padding: 0;
|
|
1623
|
+
border: 4px solid #000;
|
|
1624
|
+
border-radius: 50%;
|
|
1625
|
+
overflow: hidden;
|
|
1626
|
+
background: #FF69B4;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
.avatar {
|
|
1630
|
+
width: 120px;
|
|
1631
|
+
height: 120px;
|
|
1632
|
+
object-fit: cover;
|
|
1633
|
+
display: block;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
.user-info {
|
|
1637
|
+
flex: 1;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
.username {
|
|
1641
|
+
font-size: 48px;
|
|
1642
|
+
font-weight: 900;
|
|
1643
|
+
color: #000;
|
|
1644
|
+
margin-bottom: 8px;
|
|
1645
|
+
text-transform: uppercase;
|
|
1646
|
+
letter-spacing: -1px;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
.period {
|
|
1650
|
+
font-size: 28px;
|
|
1651
|
+
color: #000;
|
|
1652
|
+
font-weight: 700;
|
|
1653
|
+
background: #00CED1;
|
|
1654
|
+
display: inline-block;
|
|
1655
|
+
padding: 4px 12px;
|
|
1656
|
+
border: 3px solid #000;
|
|
1657
|
+
box-shadow: 4px 4px 0 #000;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
.title-section {
|
|
1661
|
+
text-align: center;
|
|
1662
|
+
margin-bottom: 48px;
|
|
1663
|
+
position: relative;
|
|
1664
|
+
z-index: 1;
|
|
1665
|
+
border-bottom: 4px solid #000;
|
|
1666
|
+
padding-bottom: 24px;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
.title {
|
|
1670
|
+
font-size: 80px;
|
|
1671
|
+
font-weight: 900;
|
|
1672
|
+
color: #000;
|
|
1673
|
+
margin-bottom: 16px;
|
|
1674
|
+
text-shadow: 6px 6px 0 #FF69B4;
|
|
1675
|
+
letter-spacing: 4px;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.subtitle {
|
|
1679
|
+
font-size: 32px;
|
|
1680
|
+
color: #000;
|
|
1681
|
+
font-weight: 700;
|
|
1682
|
+
background: #FFD700;
|
|
1683
|
+
display: inline-block;
|
|
1684
|
+
padding: 8px 24px;
|
|
1685
|
+
border: 3px solid #000;
|
|
1686
|
+
box-shadow: 6px 6px 0 #000;
|
|
1687
|
+
transform: rotate(-2deg);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
.stats-grid {
|
|
1691
|
+
display: flex;
|
|
1692
|
+
justify-content: space-between;
|
|
1693
|
+
margin-bottom: 48px;
|
|
1694
|
+
gap: 24px;
|
|
1695
|
+
position: relative;
|
|
1696
|
+
z-index: 1;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
.stat-item {
|
|
1700
|
+
text-align: center;
|
|
1701
|
+
flex: 1;
|
|
1702
|
+
padding: 32px 16px;
|
|
1703
|
+
border: 4px solid #000;
|
|
1704
|
+
box-shadow: 12px 12px 0 #000;
|
|
1705
|
+
position: relative;
|
|
1706
|
+
overflow: hidden;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
.bg-yellow { background: #FFD700; }
|
|
1710
|
+
.bg-pink { background: #FF69B4; }
|
|
1711
|
+
.bg-cyan { background: #00CED1; }
|
|
1712
|
+
|
|
1713
|
+
.stat-value {
|
|
1714
|
+
font-size: 72px;
|
|
1715
|
+
font-weight: 900;
|
|
1716
|
+
color: #000;
|
|
1717
|
+
line-height: 1;
|
|
1718
|
+
position: relative;
|
|
1719
|
+
z-index: 2;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
.stat-label {
|
|
1723
|
+
font-size: 24px;
|
|
1724
|
+
color: #000;
|
|
1725
|
+
margin-top: 8px;
|
|
1726
|
+
font-weight: 700;
|
|
1727
|
+
text-transform: uppercase;
|
|
1728
|
+
position: relative;
|
|
1729
|
+
z-index: 2;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
.trips-section {
|
|
1733
|
+
margin-bottom: 32px;
|
|
1734
|
+
position: relative;
|
|
1735
|
+
z-index: 1;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
.section-title {
|
|
1739
|
+
font-size: 36px;
|
|
1740
|
+
font-weight: 900;
|
|
1741
|
+
color: #000;
|
|
1742
|
+
margin-bottom: 24px;
|
|
1743
|
+
padding: 12px 24px;
|
|
1744
|
+
border: 4px solid #000;
|
|
1745
|
+
display: inline-block;
|
|
1746
|
+
background: #fff;
|
|
1747
|
+
box-shadow: 8px 8px 0 #000;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
.trips-list {
|
|
1751
|
+
display: flex;
|
|
1752
|
+
flex-direction: column;
|
|
1753
|
+
gap: 20px;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
.trip-item {
|
|
1757
|
+
display: flex;
|
|
1758
|
+
align-items: center;
|
|
1759
|
+
gap: 20px;
|
|
1760
|
+
padding: 20px 24px;
|
|
1761
|
+
background: #fff;
|
|
1762
|
+
border: 3px solid #000;
|
|
1763
|
+
box-shadow: 8px 8px 0 #000;
|
|
1764
|
+
transition: transform 0.2s;
|
|
1765
|
+
position: relative;
|
|
1766
|
+
overflow: hidden;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
.trip-item:nth-child(odd) {
|
|
1770
|
+
transform: rotate(0.5deg);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
.trip-item:nth-child(even) {
|
|
1774
|
+
transform: rotate(-0.5deg);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
.trip-index {
|
|
1778
|
+
width: 48px;
|
|
1779
|
+
height: 48px;
|
|
1780
|
+
background: #000;
|
|
1781
|
+
color: #fff;
|
|
1782
|
+
border-radius: 0;
|
|
1783
|
+
display: flex;
|
|
1784
|
+
align-items: center;
|
|
1785
|
+
justify-content: center;
|
|
1786
|
+
font-size: 24px;
|
|
1787
|
+
font-weight: 700;
|
|
1788
|
+
flex-shrink: 0;
|
|
1789
|
+
box-shadow: 4px 4px 0 #FF69B4;
|
|
1790
|
+
position: relative;
|
|
1791
|
+
z-index: 2;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
.trip-info {
|
|
1795
|
+
flex: 1;
|
|
1796
|
+
position: relative;
|
|
1797
|
+
z-index: 2;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
.trip-location {
|
|
1801
|
+
font-size: 28px;
|
|
1802
|
+
font-weight: 800;
|
|
1803
|
+
color: #000;
|
|
1804
|
+
margin-bottom: 4px;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
.trip-location-en {
|
|
1808
|
+
position: absolute;
|
|
1809
|
+
right: 120px;
|
|
1810
|
+
bottom: -10px;
|
|
1811
|
+
font-size: 16px;
|
|
1812
|
+
font-weight: 900;
|
|
1813
|
+
color: rgba(0,0,0,0.08);
|
|
1814
|
+
text-transform: uppercase;
|
|
1815
|
+
letter-spacing: 1px;
|
|
1816
|
+
pointer-events: none;
|
|
1817
|
+
white-space: nowrap;
|
|
1818
|
+
font-style: italic;
|
|
1819
|
+
z-index: 1;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
.trip-country {
|
|
1823
|
+
font-size: 22px;
|
|
1824
|
+
color: #000;
|
|
1825
|
+
font-weight: 500;
|
|
1826
|
+
background: #eee;
|
|
1827
|
+
display: inline-block;
|
|
1828
|
+
padding: 2px 8px;
|
|
1829
|
+
border: 2px solid #000;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
.trip-tz {
|
|
1833
|
+
font-size: 18px;
|
|
1834
|
+
font-weight: 700;
|
|
1835
|
+
color: #000;
|
|
1836
|
+
background: #FFD700;
|
|
1837
|
+
padding: 6px 12px;
|
|
1838
|
+
border: 2px solid #000;
|
|
1839
|
+
box-shadow: 3px 3px 0 #000;
|
|
1840
|
+
flex-shrink: 0;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
.more-trips {
|
|
1844
|
+
text-align: center;
|
|
1845
|
+
padding: 20px;
|
|
1846
|
+
color: #000;
|
|
1847
|
+
font-size: 24px;
|
|
1848
|
+
font-weight: 700;
|
|
1849
|
+
background: #fff;
|
|
1850
|
+
border: 3px dashed #000;
|
|
1851
|
+
margin-top: 10px;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
.footer {
|
|
1855
|
+
display: flex;
|
|
1856
|
+
justify-content: space-between;
|
|
1857
|
+
align-items: center;
|
|
1858
|
+
margin-top: 60px;
|
|
1859
|
+
padding-top: 32px;
|
|
1860
|
+
border-top: 6px solid #000;
|
|
1861
|
+
position: relative;
|
|
1862
|
+
z-index: 1;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
.brand {
|
|
1866
|
+
display: flex;
|
|
1867
|
+
align-items: center;
|
|
1868
|
+
gap: 12px;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
.brand-icon {
|
|
1872
|
+
font-size: 40px;
|
|
1873
|
+
background: #FF69B4;
|
|
1874
|
+
border: 3px solid #000;
|
|
1875
|
+
padding: 4px;
|
|
1876
|
+
line-height: 1;
|
|
1877
|
+
box-shadow: 4px 4px 0 #000;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
.brand-name {
|
|
1881
|
+
font-size: 32px;
|
|
1882
|
+
font-weight: 900;
|
|
1883
|
+
text-transform: uppercase;
|
|
1884
|
+
color: #000;
|
|
1885
|
+
font-style: italic;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
.generated-at {
|
|
1889
|
+
font-size: 20px;
|
|
1890
|
+
color: #000;
|
|
1891
|
+
font-weight: 600;
|
|
1892
|
+
background: #fff;
|
|
1893
|
+
padding: 4px 12px;
|
|
1894
|
+
border: 2px solid #000;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
.empty-state {
|
|
1898
|
+
text-align: center;
|
|
1899
|
+
padding: 80px 40px;
|
|
1900
|
+
border: 4px dashed #000;
|
|
1901
|
+
background: #fff;
|
|
1902
|
+
margin: 40px 0;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
.empty-icon {
|
|
1906
|
+
font-size: 80px;
|
|
1907
|
+
margin-bottom: 24px;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
.empty-text {
|
|
1911
|
+
font-size: 32px;
|
|
1912
|
+
font-weight: 700;
|
|
1913
|
+
color: #000;
|
|
1914
|
+
}
|
|
1915
|
+
</style>
|
|
1916
|
+
</head>
|
|
1917
|
+
<body>
|
|
1918
|
+
<div class="deco-shape-1"></div>
|
|
1919
|
+
<div class="deco-shape-2"></div>
|
|
1920
|
+
|
|
1921
|
+
<div class="container">
|
|
1922
|
+
<div class="header">
|
|
1923
|
+
<div class="avatar-ring">
|
|
1924
|
+
<img class="avatar" src="${avatarUrl || "https://ui-avatars.com/api/?name=U&background=667eea&color=fff"}" onerror="this.src='https://ui-avatars.com/api/?name=U&background=667eea&color=fff'" />
|
|
1925
|
+
</div>
|
|
1926
|
+
<div class="user-info">
|
|
1927
|
+
<div class="username">${escapeHtml2(username)}</div>
|
|
1928
|
+
<div class="period">${year}年${monthName} 总结</div>
|
|
1929
|
+
</div>
|
|
1930
|
+
</div>
|
|
1931
|
+
|
|
1932
|
+
<div class="title-section">
|
|
1933
|
+
<div class="title">猪猪日报</div>
|
|
1934
|
+
<div class="subtitle">PIG DAILY SUMMARY</div>
|
|
1935
|
+
</div>
|
|
1936
|
+
|
|
1937
|
+
${totalTrips > 0 ? `
|
|
1938
|
+
${statsHtml}
|
|
1939
|
+
|
|
1940
|
+
<div class="trips-section">
|
|
1941
|
+
<div class="section-title">本月足迹 TRACKS</div>
|
|
1942
|
+
<div class="trips-list">
|
|
1943
|
+
${tripsHtml}
|
|
1944
|
+
${moreTripsHtml}
|
|
1945
|
+
</div>
|
|
1946
|
+
</div>
|
|
1947
|
+
` : `
|
|
1948
|
+
<div class="empty-state">
|
|
1949
|
+
<div class="empty-icon"><span class="twemoji">🐷</span></div>
|
|
1950
|
+
<div class="empty-text">这个月还没有旅行记录哦~</div>
|
|
1951
|
+
</div>
|
|
1952
|
+
`}
|
|
1953
|
+
|
|
1954
|
+
<div class="footer">
|
|
1955
|
+
<div class="brand">
|
|
1956
|
+
<div class="brand-icon"><span class="twemoji">🐷</span></div>
|
|
1957
|
+
<div class="brand-name">Pig Travel</div>
|
|
1958
|
+
</div>
|
|
1959
|
+
<div class="generated-at">${(/* @__PURE__ */ new Date()).toLocaleDateString("zh-CN")}</div>
|
|
1960
|
+
</div>
|
|
1961
|
+
</div>
|
|
1962
|
+
|
|
1963
|
+
<script>
|
|
1964
|
+
async function waitForImages() {
|
|
1965
|
+
const images = Array.from(document.images);
|
|
1966
|
+
const promises = images.map(img => {
|
|
1967
|
+
if (img.complete) return Promise.resolve();
|
|
1968
|
+
return new Promise(resolve => {
|
|
1969
|
+
img.onload = () => resolve();
|
|
1970
|
+
img.onerror = () => resolve();
|
|
1971
|
+
});
|
|
1972
|
+
});
|
|
1973
|
+
await Promise.all([
|
|
1974
|
+
...promises,
|
|
1975
|
+
document.fonts.ready
|
|
1976
|
+
]);
|
|
1977
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1978
|
+
}
|
|
1979
|
+
window.renderReady = waitForImages();
|
|
1980
|
+
</script>
|
|
1981
|
+
</body>
|
|
1982
|
+
</html>
|
|
1983
|
+
`;
|
|
1984
|
+
let page = null;
|
|
1985
|
+
try {
|
|
1986
|
+
page = await ctx.puppeteer.page();
|
|
1987
|
+
if (config.debug) {
|
|
1988
|
+
page.on("console", (msg) => ctx.logger("pig").debug(`[Summary] ${msg.text()}`));
|
|
1989
|
+
}
|
|
1990
|
+
const baseHeight = 1200;
|
|
1991
|
+
const tripHeight = Math.min(logs.length, 12) * 90;
|
|
1992
|
+
const extraHeight = logs.length > 12 ? 60 : 0;
|
|
1993
|
+
const totalHeight = baseHeight + tripHeight + extraHeight + (logs.length === 0 ? 200 : 0);
|
|
1994
|
+
await page.setViewport({ width: 1080, height: Math.max(1920, totalHeight), deviceScaleFactor: 1 });
|
|
1995
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
1996
|
+
await page.evaluate(() => window["renderReady"]);
|
|
1997
|
+
const buffer = await page.screenshot({ type: "png", fullPage: true });
|
|
1998
|
+
const filename = `pig_summary_${data.userId}_${year}_${month}.png`;
|
|
1999
|
+
ctx.logger("pig").info(`月度总结卡片已生成: ${filename}`);
|
|
2000
|
+
return { buffer, filename };
|
|
2001
|
+
} catch (e) {
|
|
2002
|
+
ctx.logger("pig").error("Failed to generate summary card", e);
|
|
2003
|
+
throw e;
|
|
2004
|
+
} finally {
|
|
2005
|
+
if (page) {
|
|
2006
|
+
try {
|
|
2007
|
+
await page.close();
|
|
2008
|
+
} catch (closeError) {
|
|
2009
|
+
if (config.debug) {
|
|
2010
|
+
ctx.logger("pig").warn(`Failed to close puppeteer page: ${closeError}`);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
__name(generateMonthlySummaryCard, "generateMonthlySummaryCard");
|
|
2017
|
+
async function prepareMonthlySummary(ctx, userId, platform, username, avatarUrl, year, month) {
|
|
2018
|
+
const logs = await getMonthlyLogs(ctx, userId, platform, year, month);
|
|
2019
|
+
const countriesSet = /* @__PURE__ */ new Set();
|
|
2020
|
+
const locationsSet = /* @__PURE__ */ new Set();
|
|
2021
|
+
for (const log of logs) {
|
|
2022
|
+
countriesSet.add(log.country);
|
|
2023
|
+
locationsSet.add(log.location);
|
|
2024
|
+
}
|
|
2025
|
+
return {
|
|
2026
|
+
userId,
|
|
2027
|
+
platform,
|
|
2028
|
+
username,
|
|
2029
|
+
avatarUrl,
|
|
2030
|
+
year,
|
|
2031
|
+
month,
|
|
2032
|
+
logs,
|
|
2033
|
+
totalTrips: logs.length,
|
|
2034
|
+
countriesVisited: Array.from(countriesSet),
|
|
2035
|
+
locationsVisited: Array.from(locationsSet)
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
__name(prepareMonthlySummary, "prepareMonthlySummary");
|
|
2039
|
+
function escapeHtml2(text) {
|
|
2040
|
+
const map = {
|
|
2041
|
+
"&": "&",
|
|
2042
|
+
"<": "<",
|
|
2043
|
+
">": ">",
|
|
2044
|
+
'"': """,
|
|
2045
|
+
"'": "'"
|
|
2046
|
+
};
|
|
2047
|
+
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
2048
|
+
}
|
|
2049
|
+
__name(escapeHtml2, "escapeHtml");
|
|
2050
|
+
|
|
1301
2051
|
// src/config.ts
|
|
1302
2052
|
var import_koishi = require("koishi");
|
|
1303
2053
|
var Config = import_koishi.Schema.intersect([
|
|
1304
2054
|
import_koishi.Schema.object({
|
|
1305
2055
|
outputMode: import_koishi.Schema.union(["text", "image"]).default("image").description("输出模式:text 纯文本,image 生成精美卡片"),
|
|
1306
|
-
emojiFont: import_koishi.Schema.union([
|
|
1307
|
-
import_koishi.Schema.const("System").description("系统默认"),
|
|
1308
|
-
import_koishi.Schema.const("Noto Color Emoji").description("Noto Color Emoji"),
|
|
1309
|
-
import_koishi.Schema.const("Twemoji").description("Twemoji (Twitter Emoji)")
|
|
1310
|
-
]).default("System").description("Emoji 字体偏好(需确保容器内已安装相应字体)"),
|
|
1311
2056
|
travelMessageTemplate: import_koishi.Schema.string().default("去了 {landmark},{country}!📸").description("旅行消息模板(可用变量:{landmark} 地标名, {country} 国家名)")
|
|
1312
2057
|
}).description("基础设置"),
|
|
1313
2058
|
import_koishi.Schema.object({
|
|
1314
2059
|
llmLocationEnabled: import_koishi.Schema.boolean().default(false).description("启用后使用 LLM 动态生成全球旅行地点,关闭则使用预设地点库"),
|
|
1315
2060
|
llmLocationModel: import_koishi.Schema.dynamic("model").description("用于生成地点的模型(推荐使用快速模型如 gemini-flash)"),
|
|
1316
|
-
|
|
2061
|
+
llmLocationCustomContext: import_koishi.Schema.string().role("textarea").default("").description("自定义生成上下文(如:关注北欧神话、赛博朋克风格建筑等,留空则完全随机)"),
|
|
2062
|
+
imageSearchPrompt: import_koishi.Schema.string().default("{landmark} {country} landscape").description("搜图关键词模板(可用变量:{landmark} 地标英文名, {country} 国家英文名, {city} 城市英文名)"),
|
|
2063
|
+
unsplashAccessKey: import_koishi.Schema.string().role("secret").default("").description("Unsplash API Access Key (可选)"),
|
|
2064
|
+
pexelsApiKey: import_koishi.Schema.string().role("secret").default("").description("Pexels API Key (可选,作为 Unsplash 的补充)"),
|
|
2065
|
+
backgroundFetchMode: import_koishi.Schema.union([
|
|
2066
|
+
import_koishi.Schema.const("auto").description("自动:尽量内联远程图片,遇到易超时域名则直接使用 URL"),
|
|
2067
|
+
import_koishi.Schema.const("always").description("强制服务端拉取并内联(更稳但可能慢)"),
|
|
2068
|
+
import_koishi.Schema.const("never").description("不进行服务端拉取,直接使用 URL")
|
|
2069
|
+
]).default("auto").description("背景图服务端拉取策略"),
|
|
2070
|
+
backgroundFetchTimeoutMs: import_koishi.Schema.number().default(8e3).description("背景图服务端拉取超时(毫秒)")
|
|
1317
2071
|
}).description("地点与图片 🌍"),
|
|
1318
2072
|
import_koishi.Schema.object({
|
|
1319
2073
|
aigcEnabled: import_koishi.Schema.boolean().default(false).description("启用后使用 AI 生成小猪旅行插画(需要 media-luna 插件)"),
|
|
@@ -1331,6 +2085,7 @@ var Config = import_koishi.Schema.intersect([
|
|
|
1331
2085
|
useStorageService: import_koishi.Schema.boolean().default(true).description("使用 chatluna-storage-service 缓存图片(推荐)"),
|
|
1332
2086
|
storageCacheHours: import_koishi.Schema.number().default(24).description("图片缓存时间(小时)"),
|
|
1333
2087
|
logRetentionDays: import_koishi.Schema.number().default(45).description("旅行记录保留天数"),
|
|
2088
|
+
monthlySummaryEnabled: import_koishi.Schema.boolean().default(false).description("每月1日自动生成上月旅行总结"),
|
|
1334
2089
|
logPath: import_koishi.Schema.string().default("./data/pig/logs").description("本地日志存储路径(仅在不使用存储服务时生效)")
|
|
1335
2090
|
}).description("存储设置 💾"),
|
|
1336
2091
|
import_koishi.Schema.object({
|
|
@@ -1421,6 +2176,79 @@ function apply(ctx, config) {
|
|
|
1421
2176
|
const result = await triggerTravelSequence(ctx, config, userInfo, platform);
|
|
1422
2177
|
return formatTravelMessage(result, userId, config);
|
|
1423
2178
|
});
|
|
2179
|
+
ctx.command("pig.summary [year:number] [month:number]", "生成月度旅行总结(调试用)").option("all", "-a 生成所有用户的总结").action(async ({ session, options }, yearArg, monthArg) => {
|
|
2180
|
+
const now = /* @__PURE__ */ new Date();
|
|
2181
|
+
let year = yearArg ?? now.getFullYear();
|
|
2182
|
+
let month = monthArg ?? now.getMonth();
|
|
2183
|
+
if (!monthArg && now.getMonth() === 0) {
|
|
2184
|
+
year = now.getFullYear() - 1;
|
|
2185
|
+
month = 12;
|
|
2186
|
+
} else if (!monthArg) {
|
|
2187
|
+
month = now.getMonth();
|
|
2188
|
+
}
|
|
2189
|
+
if (month < 1 || month > 12) {
|
|
2190
|
+
return "月份必须在 1-12 之间";
|
|
2191
|
+
}
|
|
2192
|
+
await session.send(`正在生成 ${year}年${month}月 的旅行总结...`);
|
|
2193
|
+
try {
|
|
2194
|
+
if (options.all) {
|
|
2195
|
+
const users = await getUsersWithLogsInMonth(ctx, year, month);
|
|
2196
|
+
if (users.length === 0) {
|
|
2197
|
+
return `${year}年${month}月 没有任何旅行记录`;
|
|
2198
|
+
}
|
|
2199
|
+
await session.send(`找到 ${users.length} 位用户有旅行记录,开始生成...`);
|
|
2200
|
+
for (const { userId, platform } of users) {
|
|
2201
|
+
try {
|
|
2202
|
+
const summaryData = await prepareMonthlySummary(
|
|
2203
|
+
ctx,
|
|
2204
|
+
userId,
|
|
2205
|
+
platform,
|
|
2206
|
+
userId,
|
|
2207
|
+
"",
|
|
2208
|
+
year,
|
|
2209
|
+
month
|
|
2210
|
+
);
|
|
2211
|
+
const result = await generateMonthlySummaryCard(ctx, config, summaryData);
|
|
2212
|
+
const base64 = result.buffer.toString("base64");
|
|
2213
|
+
await session.send(`用户 ${userId} 的总结:
|
|
2214
|
+
${import_koishi2.segment.image(`base64://${base64}`)}`);
|
|
2215
|
+
} catch (e) {
|
|
2216
|
+
ctx.logger("pig").error(`Failed to generate summary for user ${userId}:`, e);
|
|
2217
|
+
await session.send(`生成用户 ${userId} 的总结时出错: ${e}`);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return `已完成 ${users.length} 位用户的月度总结生成`;
|
|
2221
|
+
} else {
|
|
2222
|
+
const platform = session.platform;
|
|
2223
|
+
const userId = session.userId;
|
|
2224
|
+
let avatarUrl = session.author?.avatar || "";
|
|
2225
|
+
if (!avatarUrl && (platform === "onebot" || platform === "qq" || platform.includes("qq"))) {
|
|
2226
|
+
avatarUrl = `https://q.qlogo.cn/headimg_dl?dst_uin=${userId}&spec=640`;
|
|
2227
|
+
}
|
|
2228
|
+
const userInfo = {
|
|
2229
|
+
userId,
|
|
2230
|
+
username: session.author?.nickname || session.author?.name || session.username || userId,
|
|
2231
|
+
avatarUrl
|
|
2232
|
+
};
|
|
2233
|
+
const summaryData = await prepareMonthlySummary(
|
|
2234
|
+
ctx,
|
|
2235
|
+
userId,
|
|
2236
|
+
platform,
|
|
2237
|
+
userInfo.username,
|
|
2238
|
+
userInfo.avatarUrl,
|
|
2239
|
+
year,
|
|
2240
|
+
month
|
|
2241
|
+
);
|
|
2242
|
+
const result = await generateMonthlySummaryCard(ctx, config, summaryData);
|
|
2243
|
+
const base64 = result.buffer.toString("base64");
|
|
2244
|
+
return `${import_koishi2.segment.at(userId)} ${year}年${month}月 旅行总结
|
|
2245
|
+
${import_koishi2.segment.image(`base64://${base64}`)}`;
|
|
2246
|
+
}
|
|
2247
|
+
} catch (e) {
|
|
2248
|
+
ctx.logger("pig").error("Failed to generate monthly summary:", e);
|
|
2249
|
+
return `生成月度总结失败: ${e}`;
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
1424
2252
|
ctx.middleware(async (session, next) => {
|
|
1425
2253
|
if (!config.experimentalAutoDetect) return next();
|
|
1426
2254
|
if (!session.userId || !session.content) return next();
|
|
@@ -1471,9 +2299,46 @@ function apply(ctx, config) {
|
|
|
1471
2299
|
return next();
|
|
1472
2300
|
});
|
|
1473
2301
|
ctx.cron("0 0 1 * *", async () => {
|
|
2302
|
+
if (!config.monthlySummaryEnabled) {
|
|
2303
|
+
ctx.logger("pig").debug("Monthly summary is disabled, skipping...");
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
1474
2306
|
const now = /* @__PURE__ */ new Date();
|
|
1475
|
-
|
|
1476
|
-
|
|
2307
|
+
let year = now.getFullYear();
|
|
2308
|
+
let month = now.getMonth();
|
|
2309
|
+
if (month === 0) {
|
|
2310
|
+
year -= 1;
|
|
2311
|
+
month = 12;
|
|
2312
|
+
}
|
|
2313
|
+
ctx.logger("pig").info(`Generating monthly travel handbook for ${year}/${month}...`);
|
|
2314
|
+
try {
|
|
2315
|
+
const users = await getUsersWithLogsInMonth(ctx, year, month);
|
|
2316
|
+
if (users.length === 0) {
|
|
2317
|
+
ctx.logger("pig").info(`No travel logs found for ${year}/${month}`);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
ctx.logger("pig").info(`Found ${users.length} users with travel logs for ${year}/${month}`);
|
|
2321
|
+
for (const { userId, platform } of users) {
|
|
2322
|
+
try {
|
|
2323
|
+
const summaryData = await prepareMonthlySummary(
|
|
2324
|
+
ctx,
|
|
2325
|
+
userId,
|
|
2326
|
+
platform,
|
|
2327
|
+
userId,
|
|
2328
|
+
"",
|
|
2329
|
+
year,
|
|
2330
|
+
month
|
|
2331
|
+
);
|
|
2332
|
+
await generateMonthlySummaryCard(ctx, config, summaryData);
|
|
2333
|
+
ctx.logger("pig").info(`Generated summary for user ${userId}`);
|
|
2334
|
+
} catch (e) {
|
|
2335
|
+
ctx.logger("pig").error(`Failed to generate summary for user ${userId}:`, e);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
ctx.logger("pig").info(`Monthly handbook generation completed for ${year}/${month}`);
|
|
2339
|
+
} catch (e) {
|
|
2340
|
+
ctx.logger("pig").error("Failed to generate monthly travel handbook:", e);
|
|
2341
|
+
}
|
|
1477
2342
|
});
|
|
1478
2343
|
ctx.cron("0 3 * * *", async () => {
|
|
1479
2344
|
const cutoffDate = /* @__PURE__ */ new Date();
|