hasolidit-mcp 0.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/.github/workflows/ci.yml +80 -0
- package/.mcp.json +10 -0
- package/.nvmrc +1 -0
- package/AGENTS.md +33 -0
- package/LICENSE +19 -0
- package/README.md +83 -0
- package/eslint.config.js +10 -0
- package/fixtures/AGENTS.md +25 -0
- package/fixtures/new-posts.html +3238 -0
- package/fixtures/search-results.html +1806 -0
- package/fixtures/thread.html +8792 -0
- package/package.json +25 -0
- package/playwright.config.ts +13 -0
- package/scripts/capture-fixtures.ts +44 -0
- package/src/browser.ts +26 -0
- package/src/index.ts +89 -0
- package/src/tools/new-posts.ts +82 -0
- package/src/tools/search.ts +111 -0
- package/src/tools/view-thread.ts +85 -0
- package/tests/AGENTS.md +32 -0
- package/tests/new-posts.test.ts +60 -0
- package/tests/search.test.ts +65 -0
- package/tests/test.ts +31 -0
- package/tests/view-thread.test.ts +61 -0
- package/tsconfig.json +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hasolidit-mcp",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"author": "Daniel Golub",
|
|
5
|
+
"license": "CC-BY-NC-4.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node src/index.ts",
|
|
9
|
+
"lint": "eslint src/ tests/",
|
|
10
|
+
"lint:fix": "eslint src/ tests/ --fix",
|
|
11
|
+
"test": "npx playwright test",
|
|
12
|
+
"fixtures:update": "node scripts/capture-fixtures.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
16
|
+
"playwright": "^1.52.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@eslint/js": "^10.0.1",
|
|
20
|
+
"@playwright/test": "^1.52.0",
|
|
21
|
+
"@types/node": "^24.0.0",
|
|
22
|
+
"eslint": "^10.5.0",
|
|
23
|
+
"typescript-eslint": "^8.61.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: "./tests",
|
|
5
|
+
timeout: 60000,
|
|
6
|
+
testIgnore: process.env.CI ? /search\.test/ : undefined,
|
|
7
|
+
use: {
|
|
8
|
+
headless: true,
|
|
9
|
+
userAgent:
|
|
10
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
11
|
+
locale: "he-IL",
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
|
|
4
|
+
mkdirSync("fixtures", { recursive: true });
|
|
5
|
+
|
|
6
|
+
const browser = await chromium.launch();
|
|
7
|
+
const ctx = await browser.newContext({
|
|
8
|
+
userAgent:
|
|
9
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
10
|
+
locale: "he-IL",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Search results
|
|
14
|
+
console.log("Capturing search results...");
|
|
15
|
+
let page = await ctx.newPage();
|
|
16
|
+
await page.goto("https://www.hasolidit.com/kehila/search/?type=post");
|
|
17
|
+
await page
|
|
18
|
+
.locator('input[type="search"][name="keywords"]')
|
|
19
|
+
.fill("קרן השתלמות");
|
|
20
|
+
await page.getByRole("button", { name: "חיפוש" }).click();
|
|
21
|
+
await page.waitForSelector(".contentRow", { timeout: 15000 });
|
|
22
|
+
writeFileSync("fixtures/search-results.html", await page.content());
|
|
23
|
+
console.log(" Saved fixtures/search-results.html");
|
|
24
|
+
|
|
25
|
+
// Thread
|
|
26
|
+
console.log("Capturing thread...");
|
|
27
|
+
page = await ctx.newPage();
|
|
28
|
+
await page.goto(
|
|
29
|
+
"https://www.hasolidit.com/kehila/threads/%D7%94%D7%90%D7%9D-%D7%A2%D7%9C%D7%99-%D7%9C%D7%91%D7%A8%D7%95%D7%97-%D7%9E%D7%99%D7%A9%D7%A8%D7%90%D7%9C.34810/"
|
|
30
|
+
);
|
|
31
|
+
await page.waitForSelector(".message--post", { timeout: 15000 });
|
|
32
|
+
writeFileSync("fixtures/thread.html", await page.content());
|
|
33
|
+
console.log(" Saved fixtures/thread.html");
|
|
34
|
+
|
|
35
|
+
// New posts
|
|
36
|
+
console.log("Capturing new posts...");
|
|
37
|
+
page = await ctx.newPage();
|
|
38
|
+
await page.goto("https://www.hasolidit.com/kehila/whats-new/posts/");
|
|
39
|
+
await page.waitForSelector(".structItem--thread", { timeout: 15000 });
|
|
40
|
+
writeFileSync("fixtures/new-posts.html", await page.content());
|
|
41
|
+
console.log(" Saved fixtures/new-posts.html");
|
|
42
|
+
|
|
43
|
+
await browser.close();
|
|
44
|
+
console.log("Done.");
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { chromium, type Browser, type BrowserContext } from "playwright";
|
|
2
|
+
|
|
3
|
+
let browser: Browser | null = null;
|
|
4
|
+
|
|
5
|
+
export async function getBrowser(): Promise<Browser> {
|
|
6
|
+
if (!browser) {
|
|
7
|
+
browser = await chromium.launch({ headless: true });
|
|
8
|
+
}
|
|
9
|
+
return browser;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function newContext(): Promise<BrowserContext> {
|
|
13
|
+
const b = await getBrowser();
|
|
14
|
+
return b.newContext({
|
|
15
|
+
userAgent:
|
|
16
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
17
|
+
locale: "he-IL",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function closeBrowser(): Promise<void> {
|
|
22
|
+
if (browser) {
|
|
23
|
+
await browser.close();
|
|
24
|
+
browser = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { searchForum } from "./tools/search.ts";
|
|
5
|
+
import { viewThread } from "./tools/view-thread.ts";
|
|
6
|
+
import { getNewPosts } from "./tools/new-posts.ts";
|
|
7
|
+
import { closeBrowser } from "./browser.ts";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const { version } = require("../package.json");
|
|
12
|
+
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "hasolidit",
|
|
15
|
+
version,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
server.tool(
|
|
19
|
+
"search_forum",
|
|
20
|
+
"Search the Hasolidit financial community forum. Returns titles, snippets, authors, dates, and links.",
|
|
21
|
+
{
|
|
22
|
+
query: z.string().describe("Search query (Hebrew or English)"),
|
|
23
|
+
page: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.positive()
|
|
27
|
+
.optional()
|
|
28
|
+
.default(1)
|
|
29
|
+
.describe("Results page number (default 1)"),
|
|
30
|
+
},
|
|
31
|
+
async ({ query, page }) => {
|
|
32
|
+
const result = await searchForum(query, page);
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
server.tool(
|
|
40
|
+
"view_thread",
|
|
41
|
+
"View a thread from the Hasolidit forum. Returns all posts with author, date, and content.",
|
|
42
|
+
{
|
|
43
|
+
url: z
|
|
44
|
+
.string()
|
|
45
|
+
.url()
|
|
46
|
+
.describe("Full URL of the thread to view"),
|
|
47
|
+
page: z
|
|
48
|
+
.number()
|
|
49
|
+
.int()
|
|
50
|
+
.positive()
|
|
51
|
+
.optional()
|
|
52
|
+
.default(1)
|
|
53
|
+
.describe("Thread page number (default 1)"),
|
|
54
|
+
},
|
|
55
|
+
async ({ url, page }) => {
|
|
56
|
+
const result = await viewThread(url, page);
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
server.tool(
|
|
64
|
+
"new_posts",
|
|
65
|
+
"Get the latest active threads from the Hasolidit forum. Returns recently active threads with title, author, forum, reply/view counts, and last activity info.",
|
|
66
|
+
{},
|
|
67
|
+
async () => {
|
|
68
|
+
const result = await getNewPosts();
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
const transport = new StdioServerTransport();
|
|
77
|
+
await server.connect(transport);
|
|
78
|
+
|
|
79
|
+
process.on("SIGINT", async () => {
|
|
80
|
+
await closeBrowser();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
});
|
|
83
|
+
process.on("SIGTERM", async () => {
|
|
84
|
+
await closeBrowser();
|
|
85
|
+
process.exit(0);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { newContext } from "../browser.ts";
|
|
2
|
+
|
|
3
|
+
const URL = "https://www.hasolidit.com/kehila/whats-new/posts/";
|
|
4
|
+
|
|
5
|
+
export interface NewPostThread {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
author: string;
|
|
9
|
+
forum: string;
|
|
10
|
+
replies: string;
|
|
11
|
+
views: string;
|
|
12
|
+
lastActivityDate: string;
|
|
13
|
+
lastPoster: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NewPostsResponse {
|
|
17
|
+
threads: NewPostThread[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getNewPosts(): Promise<NewPostsResponse> {
|
|
21
|
+
const context = await newContext();
|
|
22
|
+
try {
|
|
23
|
+
const p = await context.newPage();
|
|
24
|
+
|
|
25
|
+
await p.goto(URL);
|
|
26
|
+
await p.waitForSelector(".structItem--thread", { timeout: 15000 });
|
|
27
|
+
|
|
28
|
+
const threads = await p.evaluate(() => {
|
|
29
|
+
return Array.from(
|
|
30
|
+
document.querySelectorAll(".structItem--thread")
|
|
31
|
+
).map((item) => {
|
|
32
|
+
const titleEl = item.querySelector(".structItem-title a");
|
|
33
|
+
const title = titleEl?.textContent?.trim() ?? "";
|
|
34
|
+
const href = titleEl?.getAttribute("href") ?? "";
|
|
35
|
+
const url = href.startsWith("http")
|
|
36
|
+
? href
|
|
37
|
+
: `https://www.hasolidit.com${href}`;
|
|
38
|
+
|
|
39
|
+
const parts = item.querySelectorAll(".structItem-parts li");
|
|
40
|
+
const author =
|
|
41
|
+
parts[0]?.querySelector(".username")?.textContent?.trim() ?? "";
|
|
42
|
+
|
|
43
|
+
const forumEl = item.querySelector(
|
|
44
|
+
'.structItem-parts a[href*="/forums/"]'
|
|
45
|
+
);
|
|
46
|
+
const forum = forumEl?.textContent?.trim() ?? "";
|
|
47
|
+
|
|
48
|
+
const metaDds = item.querySelectorAll(
|
|
49
|
+
".structItem-cell--meta .pairs dd"
|
|
50
|
+
);
|
|
51
|
+
const replies = metaDds[0]?.textContent?.trim() ?? "0";
|
|
52
|
+
const views = metaDds[1]?.textContent?.trim() ?? "0";
|
|
53
|
+
|
|
54
|
+
const latestTime = item.querySelector(
|
|
55
|
+
".structItem-cell--latest time"
|
|
56
|
+
);
|
|
57
|
+
const lastActivityDate =
|
|
58
|
+
latestTime?.getAttribute("datetime") ?? "";
|
|
59
|
+
|
|
60
|
+
const lastPoster =
|
|
61
|
+
item
|
|
62
|
+
.querySelector(".structItem-cell--latest .username")
|
|
63
|
+
?.textContent?.trim() ?? "";
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
title,
|
|
67
|
+
url,
|
|
68
|
+
author,
|
|
69
|
+
forum,
|
|
70
|
+
replies,
|
|
71
|
+
views,
|
|
72
|
+
lastActivityDate,
|
|
73
|
+
lastPoster,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { threads };
|
|
79
|
+
} finally {
|
|
80
|
+
await context.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { newContext } from "../browser.ts";
|
|
2
|
+
|
|
3
|
+
const BASE_URL = "https://www.hasolidit.com/kehila";
|
|
4
|
+
|
|
5
|
+
export interface SearchResult {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
snippet: string;
|
|
9
|
+
author: string;
|
|
10
|
+
date: string;
|
|
11
|
+
forum: string;
|
|
12
|
+
replyCount: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SearchResponse {
|
|
16
|
+
query: string;
|
|
17
|
+
page: number;
|
|
18
|
+
totalPages: number;
|
|
19
|
+
results: SearchResult[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function searchForum(
|
|
23
|
+
query: string,
|
|
24
|
+
page: number = 1
|
|
25
|
+
): Promise<SearchResponse> {
|
|
26
|
+
const context = await newContext();
|
|
27
|
+
try {
|
|
28
|
+
const p = await context.newPage();
|
|
29
|
+
|
|
30
|
+
// Navigate to search page
|
|
31
|
+
await p.goto(`${BASE_URL}/search/?type=post`);
|
|
32
|
+
|
|
33
|
+
// Fill and submit search form
|
|
34
|
+
await p.locator('input[type="search"][name="keywords"]').fill(query);
|
|
35
|
+
await p.getByRole("button", { name: "חיפוש" }).click();
|
|
36
|
+
|
|
37
|
+
// Wait for results page
|
|
38
|
+
await p.waitForSelector(".contentRow", { timeout: 15000 });
|
|
39
|
+
|
|
40
|
+
// If requesting a page beyond 1, navigate to it
|
|
41
|
+
if (page > 1) {
|
|
42
|
+
const currentUrl = p.url();
|
|
43
|
+
const url = new URL(currentUrl);
|
|
44
|
+
url.searchParams.set("page", String(page));
|
|
45
|
+
await p.goto(url.toString());
|
|
46
|
+
await p.waitForSelector(".contentRow", { timeout: 10000 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Extract pagination
|
|
50
|
+
const totalPages = await extractTotalPages(p);
|
|
51
|
+
|
|
52
|
+
// Extract results
|
|
53
|
+
const rows = p.locator(".contentRow");
|
|
54
|
+
const count = await rows.count();
|
|
55
|
+
const results: SearchResult[] = [];
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < count; i++) {
|
|
58
|
+
const row = rows.nth(i);
|
|
59
|
+
|
|
60
|
+
const titleEl = row.locator(".contentRow-title a");
|
|
61
|
+
const title = (await titleEl.textContent()) ?? "";
|
|
62
|
+
const href = (await titleEl.getAttribute("href")) ?? "";
|
|
63
|
+
const url = href.startsWith("http")
|
|
64
|
+
? href
|
|
65
|
+
: `https://www.hasolidit.com${href}`;
|
|
66
|
+
|
|
67
|
+
const snippet =
|
|
68
|
+
(await row.locator(".contentRow-snippet").textContent()) ?? "";
|
|
69
|
+
|
|
70
|
+
const minor = row.locator(".contentRow-minor .listInline li");
|
|
71
|
+
const minorCount = await minor.count();
|
|
72
|
+
const minorTexts: string[] = [];
|
|
73
|
+
for (let j = 0; j < minorCount; j++) {
|
|
74
|
+
minorTexts.push(
|
|
75
|
+
((await minor.nth(j).textContent()) ?? "").trim()
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// First item is author, date is usually 3rd, forum is last with "פורום:" prefix
|
|
80
|
+
const author = minorTexts[0] ?? "";
|
|
81
|
+
const date = minorTexts[2] ?? "";
|
|
82
|
+
const forumEntry = minorTexts.find((t) => t.startsWith("פורום:"));
|
|
83
|
+
const forum = forumEntry?.replace("פורום: ", "") ?? "";
|
|
84
|
+
const replyEntry = minorTexts.find((t) => t.startsWith("תגובות:"));
|
|
85
|
+
const replyCount = replyEntry?.replace("תגובות: ", "") ?? null;
|
|
86
|
+
|
|
87
|
+
results.push({
|
|
88
|
+
title: title.trim(),
|
|
89
|
+
url,
|
|
90
|
+
snippet: snippet.trim(),
|
|
91
|
+
author,
|
|
92
|
+
date,
|
|
93
|
+
forum,
|
|
94
|
+
replyCount,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { query, page, totalPages, results };
|
|
99
|
+
} finally {
|
|
100
|
+
await context.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function extractTotalPages(
|
|
105
|
+
p: import("playwright").Page
|
|
106
|
+
): Promise<number> {
|
|
107
|
+
const pageNav = p.locator(".pageNav-page").last();
|
|
108
|
+
if ((await pageNav.count()) === 0) return 1;
|
|
109
|
+
const lastPageText = (await pageNav.textContent()) ?? "1";
|
|
110
|
+
return parseInt(lastPageText.trim(), 10) || 1;
|
|
111
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { newContext } from "../browser.ts";
|
|
2
|
+
|
|
3
|
+
export interface Post {
|
|
4
|
+
author: string;
|
|
5
|
+
date: string;
|
|
6
|
+
content: string;
|
|
7
|
+
postUrl: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ThreadResponse {
|
|
11
|
+
title: string;
|
|
12
|
+
url: string;
|
|
13
|
+
page: number;
|
|
14
|
+
totalPages: number;
|
|
15
|
+
posts: Post[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function viewThread(
|
|
19
|
+
url: string,
|
|
20
|
+
page: number = 1
|
|
21
|
+
): Promise<ThreadResponse> {
|
|
22
|
+
const context = await newContext();
|
|
23
|
+
try {
|
|
24
|
+
const p = await context.newPage();
|
|
25
|
+
|
|
26
|
+
// XenForo pagination: append page-N to the URL path
|
|
27
|
+
let targetUrl = url;
|
|
28
|
+
if (page > 1) {
|
|
29
|
+
targetUrl = url.replace(/\/$/, "") + `/page-${page}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await p.goto(targetUrl);
|
|
33
|
+
await p.waitForSelector(".message--post", { timeout: 15000 });
|
|
34
|
+
|
|
35
|
+
// Extract everything in a single evaluate call — avoids per-element timeouts
|
|
36
|
+
const data = await p.evaluate(() => {
|
|
37
|
+
const title =
|
|
38
|
+
document.querySelector("h1.p-title-value")?.textContent?.trim() ?? "";
|
|
39
|
+
|
|
40
|
+
const lastPageEl = document.querySelector(
|
|
41
|
+
".pageNav-page:last-child a"
|
|
42
|
+
);
|
|
43
|
+
const totalPages = lastPageEl
|
|
44
|
+
? parseInt(lastPageEl.textContent?.trim() ?? "1", 10) || 1
|
|
45
|
+
: 1;
|
|
46
|
+
|
|
47
|
+
const posts = Array.from(
|
|
48
|
+
document.querySelectorAll("article.message--post")
|
|
49
|
+
).map((article) => {
|
|
50
|
+
const author =
|
|
51
|
+
article.querySelector("h4.message-name")?.textContent?.trim() ?? "";
|
|
52
|
+
|
|
53
|
+
const timeEl = article.querySelector(
|
|
54
|
+
".message-attribution-main time"
|
|
55
|
+
);
|
|
56
|
+
const date = timeEl?.getAttribute("datetime") ?? "";
|
|
57
|
+
|
|
58
|
+
const bbWrapper = article.querySelector(".message-body .bbWrapper");
|
|
59
|
+
const content = bbWrapper?.textContent?.trim() ?? "";
|
|
60
|
+
|
|
61
|
+
const postLink = article.querySelector(
|
|
62
|
+
"a.message-attribution-gadget"
|
|
63
|
+
);
|
|
64
|
+
const postHref = postLink?.getAttribute("href") ?? "";
|
|
65
|
+
|
|
66
|
+
return { author, date, content, postHref };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { title, totalPages, posts };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const posts: Post[] = data.posts.map((p) => ({
|
|
73
|
+
author: p.author,
|
|
74
|
+
date: p.date,
|
|
75
|
+
content: p.content,
|
|
76
|
+
postUrl: p.postHref.startsWith("http")
|
|
77
|
+
? p.postHref
|
|
78
|
+
: `https://www.hasolidit.com${p.postHref}`,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
return { title: data.title, url: targetUrl, page, totalPages: data.totalPages, posts };
|
|
82
|
+
} finally {
|
|
83
|
+
await context.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
package/tests/AGENTS.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Tests
|
|
2
|
+
|
|
3
|
+
E2E tests using Playwright against the Hasolidit forum.
|
|
4
|
+
|
|
5
|
+
## Running tests
|
|
6
|
+
|
|
7
|
+
- `npm test` — runs all tests locally against the live site, or fixtures in CI
|
|
8
|
+
|
|
9
|
+
## Local vs CI
|
|
10
|
+
|
|
11
|
+
- **Locally**: all 6 tests run against the live site (real browser, real network)
|
|
12
|
+
- **CI**: tests use saved HTML fixtures from `fixtures/`. Search tests are skipped in CI because they require form interaction that can't be replayed from a static fixture.
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
Tests import `test` and `expect` from `./test.ts` (not `@playwright/test` directly). This custom fixture automatically intercepts network requests in CI and serves saved HTML from `fixtures/`. Locally it passes through to the real site unchanged.
|
|
17
|
+
|
|
18
|
+
## Adding tests
|
|
19
|
+
|
|
20
|
+
1. Import from `./test.ts`: `import { test, expect } from "./test.ts";`
|
|
21
|
+
2. Write tests using standard Playwright API — they work against the live site
|
|
22
|
+
3. If the test hits a new URL pattern, add a route mapping in `tests/test.ts` and capture a fixture for it
|
|
23
|
+
|
|
24
|
+
## Timeouts
|
|
25
|
+
|
|
26
|
+
- Test timeout: 60s (set in `playwright.config.ts`)
|
|
27
|
+
- `waitForSelector` timeout: 30s per call
|
|
28
|
+
- CI retries: 1
|
|
29
|
+
|
|
30
|
+
## Note
|
|
31
|
+
|
|
32
|
+
These tests validate that our DOM selectors match the real site structure. If the site changes its HTML, tests will fail locally — update selectors and run `npm run fixtures:update` to recapture.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { test, expect } from "./test.ts";
|
|
2
|
+
|
|
3
|
+
const URL = "https://www.hasolidit.com/kehila/whats-new/posts/";
|
|
4
|
+
|
|
5
|
+
test.describe("new_posts", () => {
|
|
6
|
+
test("loads recent threads with expected structure", async ({ page }) => {
|
|
7
|
+
await page.goto(URL);
|
|
8
|
+
await page.waitForSelector(".structItem--thread", { timeout: 30000 });
|
|
9
|
+
|
|
10
|
+
const items = page.locator(".structItem--thread");
|
|
11
|
+
const count = await items.count();
|
|
12
|
+
expect(count).toBeGreaterThan(0);
|
|
13
|
+
|
|
14
|
+
// First thread has a title with a link
|
|
15
|
+
const first = items.first();
|
|
16
|
+
const titleLink = first.locator(".structItem-title a").first();
|
|
17
|
+
const title = await titleLink.textContent();
|
|
18
|
+
expect(title?.trim().length).toBeGreaterThan(0);
|
|
19
|
+
|
|
20
|
+
const href = await titleLink.getAttribute("href");
|
|
21
|
+
expect(href).toContain("/threads/");
|
|
22
|
+
|
|
23
|
+
// Has an author
|
|
24
|
+
const author = await first
|
|
25
|
+
.locator(".structItem-parts .username")
|
|
26
|
+
.first()
|
|
27
|
+
.textContent();
|
|
28
|
+
expect(author?.trim().length).toBeGreaterThan(0);
|
|
29
|
+
|
|
30
|
+
// Has reply count
|
|
31
|
+
const replies = await first
|
|
32
|
+
.locator(".structItem-cell--meta .pairs dd")
|
|
33
|
+
.first()
|
|
34
|
+
.textContent();
|
|
35
|
+
expect(replies?.trim()).toBeTruthy();
|
|
36
|
+
|
|
37
|
+
// Has last activity date
|
|
38
|
+
const latestTime = first.locator(".structItem-cell--latest time");
|
|
39
|
+
const datetime = await latestTime.getAttribute("datetime");
|
|
40
|
+
expect(datetime).toBeTruthy();
|
|
41
|
+
|
|
42
|
+
// Has last poster
|
|
43
|
+
const lastPoster = await first
|
|
44
|
+
.locator(".structItem-cell--latest .username")
|
|
45
|
+
.textContent();
|
|
46
|
+
expect(lastPoster?.trim().length).toBeGreaterThan(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("has forum info for threads", async ({ page }) => {
|
|
50
|
+
await page.goto(URL);
|
|
51
|
+
await page.waitForSelector(".structItem--thread", { timeout: 30000 });
|
|
52
|
+
|
|
53
|
+
const first = page.locator(".structItem--thread").first();
|
|
54
|
+
const forumLink = first.locator(
|
|
55
|
+
'.structItem-parts a[href*="/forums/"]'
|
|
56
|
+
);
|
|
57
|
+
const forum = await forumLink.textContent();
|
|
58
|
+
expect(forum?.trim().length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { test, expect } from "./test.ts";
|
|
2
|
+
|
|
3
|
+
const BASE_URL = "https://www.hasolidit.com/kehila";
|
|
4
|
+
|
|
5
|
+
test.describe("search_forum", () => {
|
|
6
|
+
test("returns results for a known query", async ({ page }) => {
|
|
7
|
+
// Navigate to search page
|
|
8
|
+
await page.goto(`${BASE_URL}/search/?type=post`);
|
|
9
|
+
|
|
10
|
+
// Fill and submit
|
|
11
|
+
await page.locator('input[type="search"][name="keywords"]').fill("קרן השתלמות");
|
|
12
|
+
await page.getByRole("button", { name: "חיפוש" }).click();
|
|
13
|
+
|
|
14
|
+
// Wait for results
|
|
15
|
+
await page.waitForSelector(".contentRow", { timeout: 30000 });
|
|
16
|
+
|
|
17
|
+
// Verify results exist
|
|
18
|
+
const rows = page.locator(".contentRow");
|
|
19
|
+
const count = await rows.count();
|
|
20
|
+
expect(count).toBeGreaterThan(0);
|
|
21
|
+
|
|
22
|
+
// Verify first result has expected structure
|
|
23
|
+
const first = rows.first();
|
|
24
|
+
const title = await first.locator(".contentRow-title a").textContent();
|
|
25
|
+
expect(title?.trim().length).toBeGreaterThan(0);
|
|
26
|
+
|
|
27
|
+
const href = await first
|
|
28
|
+
.locator(".contentRow-title a")
|
|
29
|
+
.getAttribute("href");
|
|
30
|
+
expect(href).toBeTruthy();
|
|
31
|
+
|
|
32
|
+
const snippet = await first
|
|
33
|
+
.locator(".contentRow-snippet")
|
|
34
|
+
.textContent();
|
|
35
|
+
expect(snippet?.trim().length).toBeGreaterThan(0);
|
|
36
|
+
|
|
37
|
+
// Verify metadata
|
|
38
|
+
const minorItems = first.locator(".contentRow-minor .listInline li");
|
|
39
|
+
const minorCount = await minorItems.count();
|
|
40
|
+
expect(minorCount).toBeGreaterThanOrEqual(3); // author, type, date at minimum
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("pagination works", async ({ page }) => {
|
|
44
|
+
await page.goto(`${BASE_URL}/search/?type=post`);
|
|
45
|
+
await page.locator('input[type="search"][name="keywords"]').fill("מס הכנסה");
|
|
46
|
+
await page.getByRole("button", { name: "חיפוש" }).click();
|
|
47
|
+
await page.waitForSelector(".contentRow", { timeout: 30000 });
|
|
48
|
+
|
|
49
|
+
// Check pagination exists
|
|
50
|
+
const pageNav = page.locator(".pageNav-page");
|
|
51
|
+
const pageCount = await pageNav.count();
|
|
52
|
+
expect(pageCount).toBeGreaterThan(1);
|
|
53
|
+
|
|
54
|
+
// Navigate to page 2
|
|
55
|
+
const currentUrl = page.url();
|
|
56
|
+
const url = new URL(currentUrl);
|
|
57
|
+
url.searchParams.set("page", "2");
|
|
58
|
+
await page.goto(url.toString());
|
|
59
|
+
await page.waitForSelector(".contentRow", { timeout: 30000 });
|
|
60
|
+
|
|
61
|
+
// Verify page 2 has results
|
|
62
|
+
const rows = page.locator(".contentRow");
|
|
63
|
+
expect(await rows.count()).toBeGreaterThan(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/tests/test.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test as base } from "@playwright/test";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export { expect } from "@playwright/test";
|
|
6
|
+
|
|
7
|
+
const fixtureDir = join(import.meta.dirname, "..", "fixtures");
|
|
8
|
+
|
|
9
|
+
const routes: [string, string][] = [
|
|
10
|
+
["**/kehila/search/**", "search-results.html"],
|
|
11
|
+
["**/kehila/threads/**", "thread.html"],
|
|
12
|
+
["**/kehila/whats-new/posts/**", "new-posts.html"],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const test = process.env.CI
|
|
16
|
+
? base.extend({
|
|
17
|
+
page: async ({ page }, use) => {
|
|
18
|
+
for (const [pattern, file] of routes) {
|
|
19
|
+
const html = readFileSync(join(fixtureDir, file), "utf-8");
|
|
20
|
+
await page.route(pattern, (route) =>
|
|
21
|
+
route.fulfill({
|
|
22
|
+
status: 200,
|
|
23
|
+
contentType: "text/html",
|
|
24
|
+
body: html,
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
await use(page);
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
: base;
|