ffxiv_ptfinder 1.0.1

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.
Files changed (54) hide show
  1. package/Makefile +16 -0
  2. package/README.md +83 -0
  3. package/data/description_tags_ja.json +11 -0
  4. package/data/filter.json +21 -0
  5. package/data/jobs_ja.json +26 -0
  6. package/dist/cli.js +35 -0
  7. package/dist/cliOptions.js +68 -0
  8. package/dist/config.js +16 -0
  9. package/dist/debugCli.js +101 -0
  10. package/dist/descriptionRequirements.js +34 -0
  11. package/dist/descriptionTags.js +17 -0
  12. package/dist/discordWebhook.js +49 -0
  13. package/dist/envOptions.js +52 -0
  14. package/dist/extractListings.js +146 -0
  15. package/dist/fetchHtml.js +44 -0
  16. package/dist/jobMaps.js +13 -0
  17. package/dist/jobs.js +17 -0
  18. package/dist/lambdaHandler.js +31 -0
  19. package/dist/listingsPipeline.js +47 -0
  20. package/dist/lodestone.js +8 -0
  21. package/dist/lodestoneAchievements.js +16 -0
  22. package/dist/lodestoneEnrichment.js +74 -0
  23. package/dist/logger.js +45 -0
  24. package/dist/notifyCli.js +31 -0
  25. package/dist/partyText.js +133 -0
  26. package/dist/runApp.js +95 -0
  27. package/dist/searchFilter.js +184 -0
  28. package/dist/types.js +2 -0
  29. package/duties_all.json +3322 -0
  30. package/fetch-content.sh +44 -0
  31. package/package.json +24 -0
  32. package/samconfig.toml +13 -0
  33. package/src/cliOptions.ts +79 -0
  34. package/src/config.ts +16 -0
  35. package/src/debugCli.ts +124 -0
  36. package/src/descriptionRequirements.ts +36 -0
  37. package/src/descriptionTags.ts +20 -0
  38. package/src/discordWebhook.ts +49 -0
  39. package/src/envOptions.ts +72 -0
  40. package/src/extractListings.ts +121 -0
  41. package/src/fetchHtml.ts +41 -0
  42. package/src/jobMaps.ts +16 -0
  43. package/src/jobs.ts +20 -0
  44. package/src/lambdaHandler.ts +39 -0
  45. package/src/listingsPipeline.ts +50 -0
  46. package/src/lodestoneEnrichment.ts +96 -0
  47. package/src/logger.ts +60 -0
  48. package/src/notifyCli.ts +32 -0
  49. package/src/partyText.ts +161 -0
  50. package/src/runApp.ts +119 -0
  51. package/src/searchFilter.ts +259 -0
  52. package/src/types.ts +23 -0
  53. package/template.yaml +50 -0
  54. package/tsconfig.json +15 -0
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.extractListingsFromHtml = extractListingsFromHtml;
37
+ const cheerio = __importStar(require("cheerio"));
38
+ /**
39
+ * HTML から取り出したテキストを、出力用に読みやすく正規化します。
40
+ */
41
+ function normalizeText(text) {
42
+ return text
43
+ .replace(/\r\n/g, "\n")
44
+ .replace(/[ \t]+/g, " ")
45
+ .replace(/\n[ \t]+/g, "\n")
46
+ .trim();
47
+ }
48
+ /**
49
+ * class 属性(スペース区切り)を配列に分解します。
50
+ */
51
+ function splitClassList(value) {
52
+ return (value ?? "").split(/\s+/).map((s) => s.trim()).filter(Boolean);
53
+ }
54
+ /**
55
+ * 募集一覧の要素(`.listing[data-id]`)を取得します。
56
+ * 可能なら `#listings` 配下から取り、なければドキュメント全体から探索します。
57
+ */
58
+ function selectListingElements($) {
59
+ const listingsRoot = $("#listings");
60
+ return listingsRoot.length
61
+ ? listingsRoot.find("div.listing[data-id]")
62
+ : $("div.listing[data-id]");
63
+ }
64
+ /**
65
+ * `.listing` 要素から duty 情報(class と title)を抽出します。
66
+ */
67
+ function parseDuty(listing) {
68
+ const dutyNode = listing.find(".duty").first();
69
+ if (!dutyNode.length)
70
+ return undefined;
71
+ const text = normalizeText(dutyNode.text());
72
+ const title = (dutyNode.attr("title") ?? text).trim();
73
+ if (!title)
74
+ return undefined;
75
+ return { classList: splitClassList(dutyNode.attr("class")), title };
76
+ }
77
+ /**
78
+ * `.listing` 要素から description テキストを抽出します。
79
+ */
80
+ function parseDescription(listing) {
81
+ const descriptionText = listing.find(".description").first().text();
82
+ return normalizeText(descriptionText);
83
+ }
84
+ /**
85
+ * `.listing` 要素から募集者(creator)を抽出します。
86
+ */
87
+ function parseCreator(listing) {
88
+ const creatorText = listing.find(".right.meta .item.creator .text").first().text();
89
+ const normalized = normalizeText(creatorText);
90
+ return normalized.length > 0 ? normalized : undefined;
91
+ }
92
+ /**
93
+ * `.listing` 要素から party 情報(各 slot の class と title)を抽出します。
94
+ */
95
+ function parseParty($, listing) {
96
+ const party = [];
97
+ const partyRoot = listing.find(".party").first();
98
+ if (!partyRoot.length)
99
+ return party;
100
+ partyRoot.children("div").each((_, child) => {
101
+ const node = $(child);
102
+ const classList = splitClassList(node.attr("class"));
103
+ if (classList.length === 0)
104
+ return;
105
+ const title = node.attr("title") ?? undefined;
106
+ party.push(title ? { classList, title } : { classList });
107
+ });
108
+ return party;
109
+ }
110
+ /**
111
+ * `.listing` 要素を `Listing` に変換します。
112
+ */
113
+ function parseListingElement($, el) {
114
+ const listing = $(el);
115
+ const id = listing.attr("data-id");
116
+ if (!id)
117
+ return undefined;
118
+ return {
119
+ id,
120
+ dataCentre: listing.attr("data-centre") ?? undefined,
121
+ dataPfCategory: listing.attr("data-pf-category") ?? undefined,
122
+ duty: parseDuty(listing),
123
+ creator: parseCreator(listing),
124
+ requirements: [],
125
+ description: parseDescription(listing),
126
+ party: parseParty($, listing)
127
+ };
128
+ }
129
+ /**
130
+ * listings HTML から募集一覧を抽出します。
131
+ */
132
+ function extractListingsFromHtml(html) {
133
+ const $ = cheerio.load(html);
134
+ const results = [];
135
+ const seenIds = new Set();
136
+ selectListingElements($).each((_, el) => {
137
+ const parsed = parseListingElement($, el);
138
+ if (!parsed)
139
+ return;
140
+ if (seenIds.has(parsed.id))
141
+ return;
142
+ seenIds.add(parsed.id);
143
+ results.push(parsed);
144
+ });
145
+ return results;
146
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isHttpUrl = isHttpUrl;
7
+ exports.loadHtml = loadHtml;
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const promises_1 = require("node:fs/promises");
10
+ const config_1 = require("./config");
11
+ /**
12
+ * 入力が HTTP(S) URL っぽい場合に true を返します。
13
+ */
14
+ function isHttpUrl(value) {
15
+ return /^https?:\/\//i.test(value);
16
+ }
17
+ /**
18
+ * HTML をローカルファイル、または URL から読み込みます。
19
+ *
20
+ * URL の場合は `Cookie: lang=ja` を付与します。
21
+ * ※ Lambda(Node.js) の CommonJS から ESM 専用ライブラリを require できない問題を避けるため、
22
+ * CookieJar は使わずヘッダで渡します(xivpf は通常リダイレクトしない想定)。
23
+ */
24
+ async function loadHtml(input) {
25
+ if (!isHttpUrl(input)) {
26
+ return await (0, promises_1.readFile)(input, "utf8");
27
+ }
28
+ const response = await axios_1.default.get(input, {
29
+ responseType: "text",
30
+ maxRedirects: 5,
31
+ beforeRedirect: (options) => {
32
+ options.headers = options.headers ?? {};
33
+ options.headers["Cookie"] = config_1.DEFAULT_COOKIE;
34
+ options.headers["Accept-Language"] = "ja,en;q=0.8";
35
+ },
36
+ headers: {
37
+ Accept: "text/html,application/xhtml+xml",
38
+ "Accept-Language": "ja,en;q=0.8",
39
+ Cookie: config_1.DEFAULT_COOKIE,
40
+ "User-Agent": "ffxiv_ptfinder/1.0 (+https://xivpf.com)"
41
+ }
42
+ });
43
+ return response.data;
44
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildJobMaps = buildJobMaps;
4
+ /**
5
+ * party の整形に使うジョブ変換マップを作成します。
6
+ */
7
+ function buildJobMaps(jobs) {
8
+ const entries = Object.entries(jobs);
9
+ return {
10
+ codeToShort: new Map(entries.map(([code, info]) => [code, info.short])),
11
+ codeToRole: new Map(entries.map(([code, info]) => [code, info.role]))
12
+ };
13
+ }
package/dist/jobs.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadJobsJa = loadJobsJa;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_path_1 = require("node:path");
6
+ let cachedJobs;
7
+ /**
8
+ * `data/jobs_ja.json` を読み込みます(初回のみ読み取り、以後はキャッシュ)。
9
+ */
10
+ async function loadJobsJa() {
11
+ if (cachedJobs)
12
+ return cachedJobs;
13
+ const filePath = (0, node_path_1.resolve)(process.cwd(), "data/jobs_ja.json");
14
+ const raw = await (0, promises_1.readFile)(filePath, "utf8");
15
+ cachedJobs = JSON.parse(raw);
16
+ return cachedJobs;
17
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handler = handler;
4
+ const envOptions_1 = require("./envOptions");
5
+ const runApp_1 = require("./runApp");
6
+ const logger_1 = require("./logger");
7
+ /**
8
+ * AWS Lambda handler。
9
+ *
10
+ * EventBridge のスケジュール実行を想定し、設定は環境変数のみで行います。
11
+ */
12
+ async function handler(_event, context) {
13
+ const logger = (0, logger_1.createLogger)("lambda");
14
+ const startedAt = Date.now();
15
+ logger.info("invoke", { awsRequestId: context?.awsRequestId });
16
+ try {
17
+ const env = (0, envOptions_1.readEnvOptions)();
18
+ const options = (0, envOptions_1.resolveOptions)({}, env);
19
+ const result = await (0, runApp_1.runApp)(options);
20
+ logger.info("success", { awsRequestId: context?.awsRequestId, sent: result.sent, ms: Date.now() - startedAt });
21
+ return {
22
+ statusCode: 200,
23
+ headers: { "content-type": "application/json; charset=utf-8" },
24
+ body: JSON.stringify({ ok: true, sent: result.sent })
25
+ };
26
+ }
27
+ catch (err) {
28
+ logger.error("error", { awsRequestId: context?.awsRequestId, err, ms: Date.now() - startedAt });
29
+ throw err;
30
+ }
31
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildListings = buildListings;
4
+ const extractListings_1 = require("./extractListings");
5
+ const descriptionRequirements_1 = require("./descriptionRequirements");
6
+ const descriptionTags_1 = require("./descriptionTags");
7
+ const fetchHtml_1 = require("./fetchHtml");
8
+ const config_1 = require("./config");
9
+ const searchFilter_1 = require("./searchFilter");
10
+ /**
11
+ * 対象DC(Elemental/Mana/Meteor/Gaia)のみに絞り込みます。
12
+ */
13
+ function filterAllowedDataCentres(listings) {
14
+ return listings.filter((l) => l.dataCentre && config_1.ALLOWED_DATA_CENTRES.has(l.dataCentre));
15
+ }
16
+ /**
17
+ * HTML取得→募集抽出→DC/クエリ絞り込み→要件抽出、までを行います。
18
+ *
19
+ * Webhook送信やテキスト整形はこの関数の責務に含めません。
20
+ */
21
+ async function buildListings(options) {
22
+ const html = await (0, fetchHtml_1.loadHtml)(options.input);
23
+ const allListings = (0, extractListings_1.extractListingsFromHtml)(html);
24
+ const dcFiltered = filterAllowedDataCentres(allListings);
25
+ const queryFiltered = dcFiltered;
26
+ const tagsJa = await (0, descriptionTags_1.loadDescriptionTagsJa)();
27
+ const tagged = (0, descriptionRequirements_1.applyDescriptionTags)(queryFiltered, tagsJa.tags);
28
+ const preFiltered = tagged;
29
+ if (!options.searchFilter)
30
+ return preFiltered;
31
+ const f = options.searchFilter;
32
+ return preFiltered.filter((l) => {
33
+ if (!(0, searchFilter_1.matchTextFilter)(l.duty?.title ?? "", f.dutyTitle))
34
+ return false;
35
+ if (!(0, searchFilter_1.matchTextFilter)(l.creator ?? "", f.creator))
36
+ return false;
37
+ if (f.dataCentres && f.dataCentres.length > 0 && !f.dataCentres.includes(l.dataCentre ?? ""))
38
+ return false;
39
+ if (f.pfCategories && f.pfCategories.length > 0 && !f.pfCategories.includes(l.dataPfCategory ?? ""))
40
+ return false;
41
+ if (!(0, searchFilter_1.matchTextFilter)(l.description ?? "", f.description))
42
+ return false;
43
+ if (!(0, searchFilter_1.matchTextFilter)((l.requirements ?? []).join(" "), f.requirements))
44
+ return false;
45
+ return true;
46
+ });
47
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseTopCharacterUrlFromSearchHtml = exports.parseCreator = exports.fetchTopCharacterUrl = exports.buildLodestoneSearchUrl = void 0;
4
+ var ffxiv_lodestone_character_lookup_1 = require("@piyoraik/ffxiv-lodestone-character-lookup");
5
+ Object.defineProperty(exports, "buildLodestoneSearchUrl", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.buildLodestoneSearchUrl; } });
6
+ Object.defineProperty(exports, "fetchTopCharacterUrl", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.fetchTopCharacterUrl; } });
7
+ Object.defineProperty(exports, "parseCreator", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.parseCreator; } });
8
+ Object.defineProperty(exports, "parseTopCharacterUrlFromSearchHtml", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.parseTopCharacterUrlFromSearchHtml; } });
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseUltimateClearsFromAchievementHtml = exports.parseHighEndClearsFromAchievementHtml = exports.parseCharacterIdFromUrl = exports.getUltimateAchievementShortMap = exports.getUltimateAchievements = exports.getUltimateAchievementGroupMap = exports.getHighEndAchievementShortMap = exports.getHighEndAchievements = exports.getHighEndAchievementGroupMap = exports.fetchAchievementCategoryHtml = exports.buildHighEndAchievementCategoryUrl = exports.buildAchievementCategoryUrl = void 0;
4
+ var ffxiv_lodestone_character_lookup_1 = require("@piyoraik/ffxiv-lodestone-character-lookup");
5
+ Object.defineProperty(exports, "buildAchievementCategoryUrl", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.buildAchievementCategoryUrl; } });
6
+ Object.defineProperty(exports, "buildHighEndAchievementCategoryUrl", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.buildHighEndAchievementCategoryUrl; } });
7
+ Object.defineProperty(exports, "fetchAchievementCategoryHtml", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.fetchAchievementCategoryHtml; } });
8
+ Object.defineProperty(exports, "getHighEndAchievementGroupMap", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.getHighEndAchievementGroupMap; } });
9
+ Object.defineProperty(exports, "getHighEndAchievements", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.getHighEndAchievements; } });
10
+ Object.defineProperty(exports, "getHighEndAchievementShortMap", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.getHighEndAchievementShortMap; } });
11
+ Object.defineProperty(exports, "getUltimateAchievementGroupMap", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.getUltimateAchievementGroupMap; } });
12
+ Object.defineProperty(exports, "getUltimateAchievements", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.getUltimateAchievements; } });
13
+ Object.defineProperty(exports, "getUltimateAchievementShortMap", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.getUltimateAchievementShortMap; } });
14
+ Object.defineProperty(exports, "parseCharacterIdFromUrl", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.parseCharacterIdFromUrl; } });
15
+ Object.defineProperty(exports, "parseHighEndClearsFromAchievementHtml", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.parseHighEndClearsFromAchievementHtml; } });
16
+ Object.defineProperty(exports, "parseUltimateClearsFromAchievementHtml", { enumerable: true, get: function () { return ffxiv_lodestone_character_lookup_1.parseUltimateClearsFromAchievementHtml; } });
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLodestoneEnrichmentCache = createLodestoneEnrichmentCache;
4
+ exports.enrichListingWithLodestone = enrichListingWithLodestone;
5
+ const ffxiv_lodestone_character_lookup_1 = require("@piyoraik/ffxiv-lodestone-character-lookup");
6
+ const ffxiv_lodestone_character_lookup_2 = require("@piyoraik/ffxiv-lodestone-character-lookup");
7
+ /**
8
+ * Lodestone補完(検索URL/キャラクターURL/アチーブ取得)用のキャッシュを作成します。
9
+ *
10
+ * 同一募集者が複数回出てくるケースがあるため、1実行内で使い回します。
11
+ */
12
+ function createLodestoneEnrichmentCache() {
13
+ return new Map();
14
+ }
15
+ /**
16
+ * 募集者情報(`Name @ World`)から Lodestone の検索URL/キャラクターURL/アチーブ達成状況を補完します。
17
+ *
18
+ * 検索の結果、先頭にヒットしたURLが取得できない場合でも、検索URLはセットします。
19
+ */
20
+ async function enrichListingWithLodestone(listing, cache) {
21
+ const creator = listing.creator?.trim();
22
+ if (!creator)
23
+ return listing;
24
+ const cached = cache.get(creator);
25
+ if (cached) {
26
+ return {
27
+ ...listing,
28
+ creatorLodestoneSearchUrl: cached.searchUrl,
29
+ creatorLodestoneUrl: cached.characterUrl,
30
+ creatorAchievementUrl: cached.achievementUrl,
31
+ creatorUltimateClears: cached.ultimateClears,
32
+ creatorUltimateClearsStatus: cached.ultimateStatus
33
+ };
34
+ }
35
+ const info = (0, ffxiv_lodestone_character_lookup_2.parseCreator)(creator);
36
+ if (!info)
37
+ return listing;
38
+ const searchUrl = (0, ffxiv_lodestone_character_lookup_2.buildLodestoneSearchUrl)(info);
39
+ let characterUrl;
40
+ let achievementUrl;
41
+ let ultimateStatus;
42
+ let ultimateClears;
43
+ try {
44
+ characterUrl = await (0, ffxiv_lodestone_character_lookup_2.fetchTopCharacterUrl)(searchUrl);
45
+ }
46
+ catch {
47
+ // 失敗しても後続処理は継続する
48
+ characterUrl = undefined;
49
+ }
50
+ if (characterUrl) {
51
+ achievementUrl = (0, ffxiv_lodestone_character_lookup_1.buildAchievementCategoryUrl)(characterUrl);
52
+ if (achievementUrl) {
53
+ try {
54
+ const html = await (0, ffxiv_lodestone_character_lookup_1.fetchAchievementCategoryHtml)(achievementUrl);
55
+ const parsed = (0, ffxiv_lodestone_character_lookup_1.parseUltimateClearsFromAchievementHtml)(html);
56
+ ultimateStatus = parsed.status;
57
+ ultimateClears = parsed.clears;
58
+ }
59
+ catch {
60
+ ultimateStatus = "error";
61
+ ultimateClears = undefined;
62
+ }
63
+ }
64
+ }
65
+ cache.set(creator, { searchUrl, characterUrl, achievementUrl, ultimateStatus, ultimateClears });
66
+ return {
67
+ ...listing,
68
+ creatorLodestoneSearchUrl: searchUrl,
69
+ creatorLodestoneUrl: characterUrl,
70
+ creatorAchievementUrl: achievementUrl,
71
+ creatorUltimateClears: ultimateClears,
72
+ creatorUltimateClearsStatus: ultimateStatus
73
+ };
74
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLogger = createLogger;
4
+ function serializeError(err) {
5
+ if (err instanceof Error) {
6
+ return {
7
+ name: err.name,
8
+ message: err.message,
9
+ stack: err.stack
10
+ };
11
+ }
12
+ return { message: String(err) };
13
+ }
14
+ function toLogObject(params) {
15
+ const base = {
16
+ ts: new Date().toISOString(),
17
+ level: params.level,
18
+ component: params.component,
19
+ message: params.message
20
+ };
21
+ const fields = params.fields ?? {};
22
+ for (const [key, value] of Object.entries(fields)) {
23
+ if (value instanceof Error)
24
+ base[key] = serializeError(value);
25
+ else if (value !== undefined)
26
+ base[key] = value;
27
+ }
28
+ return base;
29
+ }
30
+ /**
31
+ * CloudWatch Logs で追いやすい JSON 1行ログを出すための logger を作ります。
32
+ */
33
+ function createLogger(component) {
34
+ const write = (level, message, fields) => {
35
+ // 1行JSONに揃える(CloudWatch上でフィルタしやすくする)
36
+ const obj = toLogObject({ level, component, message, fields });
37
+ process.stdout.write(JSON.stringify(obj) + "\n");
38
+ };
39
+ return {
40
+ debug: (message, fields) => write("debug", message, fields),
41
+ info: (message, fields) => write("info", message, fields),
42
+ warn: (message, fields) => write("warn", message, fields),
43
+ error: (message, fields) => write("error", message, fields)
44
+ };
45
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const cliOptions_1 = require("./cliOptions");
4
+ const envOptions_1 = require("./envOptions");
5
+ const runApp_1 = require("./runApp");
6
+ /**
7
+ * パイプ出力時(例: `| head`)の EPIPE を無視するハンドラを登録します。
8
+ */
9
+ function installStdoutEpipeHandler() {
10
+ process.stdout.on("error", (err) => {
11
+ if (err.code === "EPIPE")
12
+ process.exit(0);
13
+ throw err;
14
+ });
15
+ }
16
+ /**
17
+ * 通知(Webhook送信)用 CLI。
18
+ * EventBridge/Lambda と同じ `runApp` を呼び出して実行します。
19
+ */
20
+ async function main() {
21
+ installStdoutEpipeHandler();
22
+ const cliOptions = (0, cliOptions_1.parseCliArgs)(process.argv.slice(2));
23
+ const envOptions = (0, envOptions_1.readEnvOptions)();
24
+ const options = (0, envOptions_1.resolveOptions)(cliOptions, envOptions);
25
+ await (0, runApp_1.runApp)(options);
26
+ }
27
+ main().catch((err) => {
28
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
29
+ process.stderr.write(message + "\n");
30
+ process.exitCode = 1;
31
+ });
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitJobList = splitJobList;
4
+ exports.getRolesFromClassList = getRolesFromClassList;
5
+ exports.getRoleFromJobCode = getRoleFromJobCode;
6
+ exports.buildPartyGroups = buildPartyGroups;
7
+ exports.formatListingText = formatListingText;
8
+ exports.formatListingsText = formatListingsText;
9
+ const ffxiv_lodestone_character_lookup_1 = require("@piyoraik/ffxiv-lodestone-character-lookup");
10
+ /**
11
+ * `title` 内のスペース区切りジョブコードを分割します。
12
+ */
13
+ function splitJobList(value) {
14
+ return value
15
+ .split(/\s+/)
16
+ .map((s) => s.trim())
17
+ .filter(Boolean);
18
+ }
19
+ /**
20
+ * party の classList からロールヒント(`tank`/`healer`/`dps`)を取得します。
21
+ */
22
+ function getRolesFromClassList(classList) {
23
+ const roles = [];
24
+ if (classList.includes("tank"))
25
+ roles.push("tank");
26
+ if (classList.includes("healer"))
27
+ roles.push("healer");
28
+ if (classList.includes("dps"))
29
+ roles.push("dps");
30
+ return roles;
31
+ }
32
+ /**
33
+ * ジョブコードからロールを解決します(ルックアップマップを使用)。
34
+ */
35
+ function getRoleFromJobCode(jobCode, jobCodeToRole) {
36
+ return jobCodeToRole.get(jobCode);
37
+ }
38
+ function formatLine(label, values) {
39
+ const text = Array.from(values).join(" ");
40
+ return `${label}:${text}`;
41
+ }
42
+ function createEmptyPartyGroups() {
43
+ return {
44
+ joined: { tank: [], healer: [], dps: [] },
45
+ recruiting: { tank: new Set(), healer: new Set(), dps: new Set() }
46
+ };
47
+ }
48
+ /**
49
+ * listing の `party` 配列から、ロール別の参加/募集情報を組み立てます。
50
+ */
51
+ function buildPartyGroups(listing, codeToShort, codeToRole) {
52
+ const groups = createEmptyPartyGroups();
53
+ for (const item of listing.party) {
54
+ if (item.classList.includes("total"))
55
+ continue;
56
+ const title = item.title?.trim();
57
+ if (!title)
58
+ continue;
59
+ if (item.classList.includes("filled")) {
60
+ const role = getRoleFromJobCode(title, codeToRole);
61
+ if (!role)
62
+ continue;
63
+ groups.joined[role].push(codeToShort.get(title) ?? title);
64
+ continue;
65
+ }
66
+ const rolesFromClass = getRolesFromClassList(item.classList);
67
+ for (const job of splitJobList(title)) {
68
+ const short = codeToShort.get(job) ?? job;
69
+ const roleFromCode = getRoleFromJobCode(job, codeToRole);
70
+ if (rolesFromClass.length > 0) {
71
+ for (const role of rolesFromClass)
72
+ groups.recruiting[role].add(short);
73
+ continue;
74
+ }
75
+ if (roleFromCode)
76
+ groups.recruiting[roleFromCode].add(short);
77
+ }
78
+ }
79
+ return groups;
80
+ }
81
+ /**
82
+ * 1件の募集をテキストブロックとして整形します。
83
+ */
84
+ function formatListingText(listing, codeToShort, codeToRole) {
85
+ const { joined, recruiting } = buildPartyGroups(listing, codeToShort, codeToRole);
86
+ return [
87
+ `コンテンツ: ${listing.duty?.title ?? ""}`,
88
+ `募集者: ${listing.creator ?? ""}`,
89
+ `ロードストーン: ${listing.creatorLodestoneUrl ?? listing.creatorLodestoneSearchUrl ?? ""}`,
90
+ `絶クリア: ${formatHighEndClears(listing, "ultimate")}`,
91
+ `零式クリア: ${formatHighEndClears(listing, "savage")}`,
92
+ `DC: ${listing.dataCentre ?? ""}`,
93
+ `カテゴリ: ${listing.dataPfCategory ?? ""}`,
94
+ `要件: ${listing.requirements.join(" ")}`,
95
+ `募集文: ${listing.description}`,
96
+ "パーティ:",
97
+ "【参加ジョブ】",
98
+ formatLine("タンク", joined.tank),
99
+ formatLine("ヒーラー", joined.healer),
100
+ formatLine("DPS", joined.dps),
101
+ "【募集ジョブ】",
102
+ formatLine("タンク", recruiting.tank),
103
+ formatLine("ヒーラー", recruiting.healer),
104
+ formatLine("DPS", recruiting.dps)
105
+ ].join("\n");
106
+ }
107
+ /**
108
+ * 高難度(絶/零式)アチーブ達成状況を表示用に整形します。
109
+ */
110
+ function formatHighEndClears(listing, group) {
111
+ if (!listing.creatorAchievementUrl && !listing.creatorLodestoneUrl)
112
+ return "";
113
+ const status = listing.creatorUltimateClearsStatus;
114
+ if (status === "private_or_unavailable")
115
+ return "非公開/取得不可";
116
+ if (status === "error")
117
+ return "取得エラー";
118
+ const clears = listing.creatorUltimateClears ?? [];
119
+ if (clears.length === 0)
120
+ return "なし";
121
+ const shortMap = (0, ffxiv_lodestone_character_lookup_1.getUltimateAchievementShortMap)();
122
+ const groupMap = (0, ffxiv_lodestone_character_lookup_1.getUltimateAchievementGroupMap)();
123
+ const short = clears
124
+ .filter((name) => groupMap.get(name) === group)
125
+ .map((name) => shortMap.get(name) ?? name);
126
+ return short.length > 0 ? short.join(" / ") : "なし";
127
+ }
128
+ /**
129
+ * 複数募集を空行区切りで整形します。
130
+ */
131
+ function formatListingsText(listings, codeToShort, codeToRole) {
132
+ return listings.map((l) => formatListingText(l, codeToShort, codeToRole)).join("\n\n");
133
+ }