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.
- package/Makefile +16 -0
- package/README.md +83 -0
- package/data/description_tags_ja.json +11 -0
- package/data/filter.json +21 -0
- package/data/jobs_ja.json +26 -0
- package/dist/cli.js +35 -0
- package/dist/cliOptions.js +68 -0
- package/dist/config.js +16 -0
- package/dist/debugCli.js +101 -0
- package/dist/descriptionRequirements.js +34 -0
- package/dist/descriptionTags.js +17 -0
- package/dist/discordWebhook.js +49 -0
- package/dist/envOptions.js +52 -0
- package/dist/extractListings.js +146 -0
- package/dist/fetchHtml.js +44 -0
- package/dist/jobMaps.js +13 -0
- package/dist/jobs.js +17 -0
- package/dist/lambdaHandler.js +31 -0
- package/dist/listingsPipeline.js +47 -0
- package/dist/lodestone.js +8 -0
- package/dist/lodestoneAchievements.js +16 -0
- package/dist/lodestoneEnrichment.js +74 -0
- package/dist/logger.js +45 -0
- package/dist/notifyCli.js +31 -0
- package/dist/partyText.js +133 -0
- package/dist/runApp.js +95 -0
- package/dist/searchFilter.js +184 -0
- package/dist/types.js +2 -0
- package/duties_all.json +3322 -0
- package/fetch-content.sh +44 -0
- package/package.json +24 -0
- package/samconfig.toml +13 -0
- package/src/cliOptions.ts +79 -0
- package/src/config.ts +16 -0
- package/src/debugCli.ts +124 -0
- package/src/descriptionRequirements.ts +36 -0
- package/src/descriptionTags.ts +20 -0
- package/src/discordWebhook.ts +49 -0
- package/src/envOptions.ts +72 -0
- package/src/extractListings.ts +121 -0
- package/src/fetchHtml.ts +41 -0
- package/src/jobMaps.ts +16 -0
- package/src/jobs.ts +20 -0
- package/src/lambdaHandler.ts +39 -0
- package/src/listingsPipeline.ts +50 -0
- package/src/lodestoneEnrichment.ts +96 -0
- package/src/logger.ts +60 -0
- package/src/notifyCli.ts +32 -0
- package/src/partyText.ts +161 -0
- package/src/runApp.ts +119 -0
- package/src/searchFilter.ts +259 -0
- package/src/types.ts +23 -0
- package/template.yaml +50 -0
- package/tsconfig.json +15 -0
package/dist/runApp.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runApp = runApp;
|
|
4
|
+
const jobs_1 = require("./jobs");
|
|
5
|
+
const config_1 = require("./config");
|
|
6
|
+
const discordWebhook_1 = require("./discordWebhook");
|
|
7
|
+
const partyText_1 = require("./partyText");
|
|
8
|
+
const listingsPipeline_1 = require("./listingsPipeline");
|
|
9
|
+
const searchFilter_1 = require("./searchFilter");
|
|
10
|
+
const logger_1 = require("./logger");
|
|
11
|
+
const jobMaps_1 = require("./jobMaps");
|
|
12
|
+
const lodestoneEnrichment_1 = require("./lodestoneEnrichment");
|
|
13
|
+
function summarizeFilter(filter) {
|
|
14
|
+
if (!filter)
|
|
15
|
+
return undefined;
|
|
16
|
+
return { keys: Object.keys(filter) };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Discord Webhook に送信します(最大 `limit` 件、1募集=1メッセージ)。
|
|
20
|
+
*/
|
|
21
|
+
async function sendToDiscordWebhook(params) {
|
|
22
|
+
const lodestoneCache = (0, lodestoneEnrichment_1.createLodestoneEnrichmentCache)();
|
|
23
|
+
let sent = 0;
|
|
24
|
+
let scanned = 0;
|
|
25
|
+
let filteredOut = 0;
|
|
26
|
+
for (const listing of params.listings) {
|
|
27
|
+
if (sent >= params.limit)
|
|
28
|
+
break;
|
|
29
|
+
scanned++;
|
|
30
|
+
const enriched = await (0, lodestoneEnrichment_1.enrichListingWithLodestone)(listing, lodestoneCache);
|
|
31
|
+
const text = (0, partyText_1.formatListingText)(enriched, params.codeToShort, params.codeToRole);
|
|
32
|
+
const ok = await (0, searchFilter_1.matchListing)({
|
|
33
|
+
listing: enriched,
|
|
34
|
+
filter: params.searchFilter,
|
|
35
|
+
formattedText: text
|
|
36
|
+
});
|
|
37
|
+
if (!ok) {
|
|
38
|
+
filteredOut++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const content = (0, discordWebhook_1.toDiscordCodeBlock)(text);
|
|
42
|
+
await (0, discordWebhook_1.postDiscordWebhook)(params.webhookUrl, content);
|
|
43
|
+
await (0, discordWebhook_1.sleep)(350);
|
|
44
|
+
sent++;
|
|
45
|
+
}
|
|
46
|
+
params.logger?.info("send_summary", { scanned, filteredOut, sent, limit: params.limit });
|
|
47
|
+
if (sent === 0) {
|
|
48
|
+
const message = "該当の募集は見つかりませんでした";
|
|
49
|
+
await (0, discordWebhook_1.postDiscordWebhook)(params.webhookUrl, message);
|
|
50
|
+
params.logger?.info("no_match", { posted: true });
|
|
51
|
+
}
|
|
52
|
+
return sent;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* アプリのコア処理(CLI/Lambda共通)。
|
|
56
|
+
*
|
|
57
|
+
* - HTML 取得(ファイル or URL)
|
|
58
|
+
* - 募集一覧を抽出
|
|
59
|
+
* - DCフィルタ / クエリフィルタ
|
|
60
|
+
* - description タグを requirements に抽出
|
|
61
|
+
* - JSON/text 出力、または webhook 送信
|
|
62
|
+
*/
|
|
63
|
+
async function runApp(options) {
|
|
64
|
+
const logger = (0, logger_1.createLogger)("runApp");
|
|
65
|
+
const startAt = Date.now();
|
|
66
|
+
const fileFilter = options.searchFilterFile ? await (0, searchFilter_1.loadListingSearchFilterFromFile)(options.searchFilterFile) : undefined;
|
|
67
|
+
const mergedSearchFilter = fileFilter ?? options.searchFilter;
|
|
68
|
+
logger.info("start", {
|
|
69
|
+
limit: options.limit,
|
|
70
|
+
filterFile: options.searchFilterFile,
|
|
71
|
+
filterLoaded: Boolean(fileFilter),
|
|
72
|
+
filterSummary: summarizeFilter(mergedSearchFilter)
|
|
73
|
+
});
|
|
74
|
+
const buildStartAt = Date.now();
|
|
75
|
+
const taggedListings = await (0, listingsPipeline_1.buildListings)({
|
|
76
|
+
input: options.input,
|
|
77
|
+
searchFilter: mergedSearchFilter
|
|
78
|
+
});
|
|
79
|
+
logger.info("listings_built", { count: taggedListings.length, ms: Date.now() - buildStartAt });
|
|
80
|
+
const jobsJa = await (0, jobs_1.loadJobsJa)();
|
|
81
|
+
const { codeToShort, codeToRole } = (0, jobMaps_1.buildJobMaps)(jobsJa.jobs);
|
|
82
|
+
const limit = options.limit ?? config_1.DEFAULT_WEBHOOK_LIMIT;
|
|
83
|
+
logger.info("send_start", { limit, candidates: taggedListings.length });
|
|
84
|
+
const sent = await sendToDiscordWebhook({
|
|
85
|
+
webhookUrl: options.webhookUrl,
|
|
86
|
+
limit,
|
|
87
|
+
listings: taggedListings,
|
|
88
|
+
codeToShort,
|
|
89
|
+
codeToRole,
|
|
90
|
+
searchFilter: mergedSearchFilter,
|
|
91
|
+
logger
|
|
92
|
+
});
|
|
93
|
+
logger.info("done", { sent, ms: Date.now() - startAt });
|
|
94
|
+
return { mode: "webhook", sent };
|
|
95
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.matchTextFilter = matchTextFilter;
|
|
4
|
+
exports.matchListing = matchListing;
|
|
5
|
+
exports.parseListingSearchFilterJson = parseListingSearchFilterJson;
|
|
6
|
+
exports.loadListingSearchFilterFromFile = loadListingSearchFilterFromFile;
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const partyText_1 = require("./partyText");
|
|
10
|
+
const jobs_1 = require("./jobs");
|
|
11
|
+
const ffxiv_lodestone_character_lookup_1 = require("@piyoraik/ffxiv-lodestone-character-lookup");
|
|
12
|
+
function normalizeTerms(terms) {
|
|
13
|
+
return terms.map((t) => t.trim()).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
function getMode(mode) {
|
|
16
|
+
return mode ?? "and";
|
|
17
|
+
}
|
|
18
|
+
function matchText(value, filter) {
|
|
19
|
+
if (!filter)
|
|
20
|
+
return true;
|
|
21
|
+
const terms = normalizeTerms(filter.terms ?? []);
|
|
22
|
+
if (terms.length === 0)
|
|
23
|
+
return true;
|
|
24
|
+
const mode = getMode(filter.mode);
|
|
25
|
+
return mode === "and"
|
|
26
|
+
? terms.every((t) => value.includes(t))
|
|
27
|
+
: terms.some((t) => value.includes(t));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* テキストフィルタ(部分一致 × and/or)の一致判定を行います。
|
|
31
|
+
*/
|
|
32
|
+
function matchTextFilter(value, filter) {
|
|
33
|
+
return matchText(value, filter);
|
|
34
|
+
}
|
|
35
|
+
function matchInList(value, allowList) {
|
|
36
|
+
if (!allowList || allowList.length === 0)
|
|
37
|
+
return true;
|
|
38
|
+
if (!value)
|
|
39
|
+
return false;
|
|
40
|
+
return allowList.includes(value);
|
|
41
|
+
}
|
|
42
|
+
function matchStringArray(values, filter) {
|
|
43
|
+
if (!filter)
|
|
44
|
+
return true;
|
|
45
|
+
return matchText(values.join(" "), filter);
|
|
46
|
+
}
|
|
47
|
+
function filterRoleItems(roleValues, filter) {
|
|
48
|
+
if (!filter)
|
|
49
|
+
return true;
|
|
50
|
+
const acrossRolesMode = getMode(filter.acrossRolesMode ?? filter.mode);
|
|
51
|
+
const withinRoleMode = getMode(filter.withinRoleMode ?? filter.roleMode ?? "or");
|
|
52
|
+
const roles = ["tank", "healer", "dps"];
|
|
53
|
+
const checks = roles
|
|
54
|
+
.filter((r) => Array.isArray(filter[r]) && (filter[r] ?? []).length > 0)
|
|
55
|
+
.map((role) => {
|
|
56
|
+
const expected = new Set(normalizeTerms(filter[role] ?? []));
|
|
57
|
+
const actual = new Set(roleValues[role] ?? []);
|
|
58
|
+
const list = Array.from(expected);
|
|
59
|
+
return withinRoleMode === "and"
|
|
60
|
+
? list.every((v) => actual.has(v))
|
|
61
|
+
: list.some((v) => actual.has(v));
|
|
62
|
+
});
|
|
63
|
+
if (checks.length === 0)
|
|
64
|
+
return true;
|
|
65
|
+
return acrossRolesMode === "and" ? checks.every(Boolean) : checks.some(Boolean);
|
|
66
|
+
}
|
|
67
|
+
function computeAchievementShortsByGroup(listing) {
|
|
68
|
+
const shortMap = (0, ffxiv_lodestone_character_lookup_1.getUltimateAchievementShortMap)();
|
|
69
|
+
const groupMap = (0, ffxiv_lodestone_character_lookup_1.getUltimateAchievementGroupMap)();
|
|
70
|
+
const result = { ultimate: [], savage: [] };
|
|
71
|
+
const clears = listing.creatorUltimateClears ?? [];
|
|
72
|
+
for (const name of clears) {
|
|
73
|
+
const group = groupMap.get(name);
|
|
74
|
+
if (!group)
|
|
75
|
+
continue;
|
|
76
|
+
const short = shortMap.get(name) ?? name;
|
|
77
|
+
result[group].push(short);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
function matchAchievements(listing, filter) {
|
|
82
|
+
if (!filter)
|
|
83
|
+
return true;
|
|
84
|
+
const status = listing.creatorUltimateClearsStatus;
|
|
85
|
+
if (status === "private_or_unavailable" || status === "error")
|
|
86
|
+
return false;
|
|
87
|
+
const actual = computeAchievementShortsByGroup(listing);
|
|
88
|
+
const mode = getMode(filter.mode);
|
|
89
|
+
const checks = ["ultimate", "savage"]
|
|
90
|
+
.filter((g) => Array.isArray(filter[g]) && (filter[g] ?? []).length > 0)
|
|
91
|
+
.map((group) => {
|
|
92
|
+
const expected = new Set(normalizeTerms(filter[group] ?? []));
|
|
93
|
+
const got = new Set(actual[group] ?? []);
|
|
94
|
+
return Array.from(expected).every((v) => got.has(v));
|
|
95
|
+
});
|
|
96
|
+
if (checks.length === 0)
|
|
97
|
+
return true;
|
|
98
|
+
return mode === "and" ? checks.every(Boolean) : checks.some(Boolean);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* フィルタのうち party 条件を評価するためのジョブ辞書を読み込み、略称ベースのグルーピングを返します。
|
|
102
|
+
*/
|
|
103
|
+
async function buildPartyRoleValues(listing) {
|
|
104
|
+
const jobsJa = await (0, jobs_1.loadJobsJa)();
|
|
105
|
+
const entries = Object.entries(jobsJa.jobs);
|
|
106
|
+
const codeToShort = new Map(entries.map(([code, info]) => [code, info.short]));
|
|
107
|
+
const codeToRole = new Map(entries.map(([code, info]) => [code, info.role]));
|
|
108
|
+
const groups = (0, partyText_1.buildPartyGroups)(listing, codeToShort, codeToRole);
|
|
109
|
+
return {
|
|
110
|
+
joined: groups.joined,
|
|
111
|
+
recruiting: {
|
|
112
|
+
tank: Array.from(groups.recruiting.tank),
|
|
113
|
+
healer: Array.from(groups.recruiting.healer),
|
|
114
|
+
dps: Array.from(groups.recruiting.dps)
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 募集(Listing)がフィルタ条件に一致するか判定します。
|
|
120
|
+
*
|
|
121
|
+
* - `formattedText` は呼び出し元で生成した文字列を渡してください(Lodestone情報などを含めるため)
|
|
122
|
+
*/
|
|
123
|
+
async function matchListing(params) {
|
|
124
|
+
const { listing, filter, formattedText } = params;
|
|
125
|
+
if (!filter)
|
|
126
|
+
return true;
|
|
127
|
+
if (!matchText(listing.duty?.title ?? "", filter.dutyTitle))
|
|
128
|
+
return false;
|
|
129
|
+
if (!matchText(listing.creator ?? "", filter.creator))
|
|
130
|
+
return false;
|
|
131
|
+
if (!matchInList(listing.dataCentre, filter.dataCentres))
|
|
132
|
+
return false;
|
|
133
|
+
if (!matchInList(listing.dataPfCategory, filter.pfCategories))
|
|
134
|
+
return false;
|
|
135
|
+
if (!matchStringArray(listing.requirements ?? [], filter.requirements))
|
|
136
|
+
return false;
|
|
137
|
+
if (!matchText(listing.description ?? "", filter.description))
|
|
138
|
+
return false;
|
|
139
|
+
if (filter.achievements && !matchAchievements(listing, filter.achievements))
|
|
140
|
+
return false;
|
|
141
|
+
if (filter.formattedText && !matchText(formattedText ?? "", filter.formattedText))
|
|
142
|
+
return false;
|
|
143
|
+
if (filter.party) {
|
|
144
|
+
const roleValues = await buildPartyRoleValues(listing);
|
|
145
|
+
if (!filterRoleItems(roleValues.joined, filter.party.joined))
|
|
146
|
+
return false;
|
|
147
|
+
if (!filterRoleItems(roleValues.recruiting, filter.party.recruiting))
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* JSON文字列から ListingSearchFilter を読み取ります(不正な場合は undefined)。
|
|
154
|
+
*/
|
|
155
|
+
function parseListingSearchFilterJson(raw) {
|
|
156
|
+
const value = raw?.trim();
|
|
157
|
+
if (!value)
|
|
158
|
+
return undefined;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(value);
|
|
161
|
+
if (!parsed || typeof parsed !== "object")
|
|
162
|
+
return undefined;
|
|
163
|
+
return parsed;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* JSONファイルから ListingSearchFilter を読み込みます。
|
|
171
|
+
* ファイルが存在しない/読めない/JSONが不正な場合は undefined を返します。
|
|
172
|
+
*/
|
|
173
|
+
async function loadListingSearchFilterFromFile(filePath) {
|
|
174
|
+
const path = filePath?.trim();
|
|
175
|
+
if (!path)
|
|
176
|
+
return undefined;
|
|
177
|
+
try {
|
|
178
|
+
const raw = await (0, promises_1.readFile)((0, node_path_1.resolve)(process.cwd(), path), "utf8");
|
|
179
|
+
return parseListingSearchFilterJson(raw);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/types.js
ADDED