opencode-skills-collection 4.0.17 → 4.0.19

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 (83) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +4 -1
  2. package/bundled-skills/2slides-ppt-generator/scripts/download_slides_pages_voices.py +59 -14
  3. package/bundled-skills/antigravity-maintainer-batch-release/SKILL.md +1 -0
  4. package/bundled-skills/apify-audience-analysis/reference/scripts/run_actor.js +9 -10
  5. package/bundled-skills/apify-brand-reputation-monitoring/reference/scripts/run_actor.js +9 -10
  6. package/bundled-skills/apify-competitor-intelligence/reference/scripts/run_actor.js +9 -10
  7. package/bundled-skills/apify-content-analytics/reference/scripts/run_actor.js +9 -10
  8. package/bundled-skills/apify-ecommerce/reference/scripts/run_actor.js +9 -10
  9. package/bundled-skills/apify-influencer-discovery/reference/scripts/run_actor.js +9 -10
  10. package/bundled-skills/apify-lead-generation/reference/scripts/run_actor.js +9 -10
  11. package/bundled-skills/apify-market-research/reference/scripts/run_actor.js +9 -10
  12. package/bundled-skills/apify-trend-analysis/reference/scripts/run_actor.js +9 -10
  13. package/bundled-skills/apify-ultimate-scraper/reference/scripts/run_actor.js +9 -10
  14. package/bundled-skills/competitor-analysis/scripts/compile_report.mjs +22 -6
  15. package/bundled-skills/context-guardian/scripts/context_snapshot.py +45 -10
  16. package/bundled-skills/cowork-to-code-bridge/SKILL.md +243 -0
  17. package/bundled-skills/deploy-to-vercel/resources/deploy.sh +13 -0
  18. package/bundled-skills/docs/integrations/jetski-cortex.md +3 -3
  19. package/bundled-skills/docs/integrations/jetski-gemini-loader/README.md +1 -1
  20. package/bundled-skills/docs/maintainers/repo-growth-seo.md +1 -1
  21. package/bundled-skills/docs/maintainers/skills-update-guide.md +1 -1
  22. package/bundled-skills/docs/users/aas-core.md +1 -1
  23. package/bundled-skills/docs/users/bundles.md +1 -1
  24. package/bundled-skills/docs/users/claude-code-skills.md +1 -1
  25. package/bundled-skills/docs/users/faq.md +20 -7
  26. package/bundled-skills/docs/users/gemini-cli-skills.md +1 -1
  27. package/bundled-skills/docs/users/getting-started.md +18 -6
  28. package/bundled-skills/docs/users/kiro-integration.md +1 -1
  29. package/bundled-skills/docs/users/usage.md +3 -3
  30. package/bundled-skills/docs/users/visual-guide.md +4 -4
  31. package/bundled-skills/fact-check-x-complete/SKILL.md +232 -0
  32. package/bundled-skills/find-complementary-founders/SKILL.md +47 -9
  33. package/bundled-skills/find-complementary-founders/references/community-growth.md +39 -28
  34. package/bundled-skills/find-complementary-founders/references/moltbook.md +2 -1
  35. package/bundled-skills/find-complementary-founders/references/owner-onboarding.ru.md +29 -14
  36. package/bundled-skills/find-complementary-founders/references/privacy-safety.md +3 -0
  37. package/bundled-skills/find-complementary-founders/references/profile-schema.md +3 -2
  38. package/bundled-skills/find-complementary-founders/scripts/github_thread.py +1 -1
  39. package/bundled-skills/find-complementary-founders/scripts/moltbook_publish.py +19 -6
  40. package/bundled-skills/find-complementary-founders/scripts/test_moltbook_publish.py +151 -0
  41. package/bundled-skills/hugging-face-model-trainer/scripts/convert_to_gguf.py +20 -10
  42. package/bundled-skills/instagram/scripts/config.py +22 -4
  43. package/bundled-skills/instagram/scripts/db.py +8 -0
  44. package/bundled-skills/junta-leiloeiros/scripts/export.py +11 -2
  45. package/bundled-skills/junta-leiloeiros/scripts/serve_api.py +5 -1
  46. package/bundled-skills/loki-mode/CLAUDE.md +1 -1
  47. package/bundled-skills/loki-mode/CONTEXT-EXPORT.md +3 -3
  48. package/bundled-skills/loki-mode/INSTALLATION.md +3 -3
  49. package/bundled-skills/loki-mode/README.md +11 -11
  50. package/bundled-skills/loki-mode/SKILL.md +2 -2
  51. package/bundled-skills/loki-mode/autonomy/README.md +8 -8
  52. package/bundled-skills/loki-mode/autonomy/run.sh +23 -54
  53. package/bundled-skills/loki-mode/demo/README.md +3 -3
  54. package/bundled-skills/loki-mode/demo/run-demo.sh +3 -3
  55. package/bundled-skills/loki-mode/demo/vhs-tape.tape +1 -1
  56. package/bundled-skills/loki-mode/demo/voice-over-script.md +3 -3
  57. package/bundled-skills/loki-mode/docs/COMPETITIVE-ANALYSIS.md +2 -2
  58. package/bundled-skills/loki-mode/docs/screenshots/README.md +1 -1
  59. package/bundled-skills/loki-mode/integrations/vibe-kanban.md +1 -1
  60. package/bundled-skills/loki-mode/references/agent-types.md +2 -2
  61. package/bundled-skills/loki-mode/scripts/loki-wrapper.sh +5 -4
  62. package/bundled-skills/maintain-codex-wiki/SKILL.md +340 -0
  63. package/bundled-skills/notebooklm/README.md +4 -3
  64. package/bundled-skills/notebooklm/SKILL.md +3 -3
  65. package/bundled-skills/notebooklm/scripts/auth_manager.py +6 -4
  66. package/bundled-skills/notebooklm/scripts/browser_utils.py +2 -1
  67. package/bundled-skills/notebooklm/scripts/config.py +31 -1
  68. package/bundled-skills/pptx-official/scripts/html2pptx.js +37 -8
  69. package/bundled-skills/senior-frontend/scripts/frontend_scaffolder.py +6 -0
  70. package/bundled-skills/skill-creator/scripts/init_skill.py +18 -2
  71. package/bundled-skills/telegram/SKILL.md +2 -2
  72. package/bundled-skills/telegram/assets/boilerplate/nodejs/.env.example +1 -1
  73. package/bundled-skills/telegram/assets/boilerplate/nodejs/src/bot-client.ts +7 -6
  74. package/bundled-skills/telegram/assets/boilerplate/nodejs/src/index.ts +3 -0
  75. package/bundled-skills/telegram/assets/boilerplate/python/.env.example +1 -1
  76. package/bundled-skills/telegram/assets/boilerplate/python/webhook_server.py +9 -4
  77. package/bundled-skills/telegram/references/webhook-setup.md +10 -9
  78. package/bundled-skills/youtube-notetaker/SKILL.md +1 -1
  79. package/bundled-skills/youtube-notetaker/scripts/extract_slides.py +19 -6
  80. package/bundled-skills/youtube-notetaker/scripts/setup.sh +1 -0
  81. package/bundled-skills/youtube-notetaker/scripts/write_library_item.py +15 -4
  82. package/package.json +1 -1
  83. package/skills_index.json +129 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "updatedAt": "2026-07-31T01:29:46.374Z",
3
+ "updatedAt": "2026-08-02T01:28:40.906Z",
4
4
  "entries": [
5
5
  "00-andruia-consultant",
6
6
  "007",
@@ -520,6 +520,7 @@
520
520
  "copywriting-psychologist",
521
521
  "core-components",
522
522
  "cost-optimization",
523
+ "cowork-to-code-bridge",
523
524
  "cpp-pro",
524
525
  "cqrs-implementation",
525
526
  "create-branch",
@@ -767,6 +768,7 @@
767
768
  "expo-ui-jetpack-compose",
768
769
  "expo-ui-swift-ui",
769
770
  "fable-safe-prompt",
771
+ "fact-check-x-complete",
770
772
  "faf-context",
771
773
  "faf-expert",
772
774
  "faf-go",
@@ -1146,6 +1148,7 @@
1146
1148
  "mailtrap-sending-emails",
1147
1149
  "mailtrap-setting-up-sending-domain",
1148
1150
  "mailtrap-testing-with-sandbox",
1151
+ "maintain-codex-wiki",
1149
1152
  "make-automation",
1150
1153
  "makepad-animation",
1151
1154
  "makepad-basics",
@@ -11,6 +11,9 @@ import argparse
11
11
  import ipaddress
12
12
  import re
13
13
  import socket
14
+ import ssl
15
+ import http.client
16
+ import tempfile
14
17
  import requests
15
18
  from urllib.parse import urlparse
16
19
  from typing import Optional, Dict, Any
@@ -40,15 +43,56 @@ def validate_job_id(job_id: str) -> str:
40
43
  return job_id
41
44
 
42
45
 
43
- def validate_public_https_url(url: str) -> str:
46
+ def validate_public_https_url(url: str) -> tuple[str, str]:
44
47
  parsed = urlparse(url)
45
- if parsed.scheme != "https" or not parsed.hostname:
48
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.port not in (None, 443):
46
49
  raise ValueError("Download URL must be HTTPS")
50
+ public_ips = []
47
51
  for info in socket.getaddrinfo(parsed.hostname, None):
48
52
  ip = ipaddress.ip_address(info[4][0])
49
- if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
53
+ if not ip.is_global:
50
54
  raise ValueError("Download URL resolves to a non-public address")
51
- return url
55
+ public_ips.append(str(ip))
56
+ if not public_ips:
57
+ raise ValueError("Download URL did not resolve")
58
+ return url, public_ips[0]
59
+
60
+
61
+ def download_pinned_https(url: str, pinned_ip: str, output_path: Path, maximum_bytes: int = 128 * 1024 * 1024) -> int:
62
+ """Download once from the verified IP while preserving TLS SNI/hostname checks."""
63
+ parsed = urlparse(url)
64
+ raw_socket = socket.create_connection((pinned_ip, 443), timeout=30)
65
+ tls_context = ssl.create_default_context()
66
+ tls_context.minimum_version = ssl.TLSVersion.TLSv1_2
67
+ tls_socket = tls_context.wrap_socket(raw_socket, server_hostname=parsed.hostname)
68
+ request_target = parsed.path or "/"
69
+ if parsed.query:
70
+ request_target += f"?{parsed.query}"
71
+ host = parsed.hostname
72
+ tls_socket.sendall(
73
+ f"GET {request_target} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: 2slides-downloader/1\r\nAccept: application/zip\r\nConnection: close\r\n\r\n".encode("ascii")
74
+ )
75
+ response = http.client.HTTPResponse(tls_socket)
76
+ response.begin()
77
+ if 300 <= response.status < 400:
78
+ raise ValueError("Download redirects are refused; request a fresh final URL from 2slides")
79
+ if response.status != 200:
80
+ raise ValueError(f"Download failed with HTTP {response.status}")
81
+ declared = response.getheader("Content-Length")
82
+ if declared and int(declared) > maximum_bytes:
83
+ raise ValueError("Download exceeds the 128 MiB limit")
84
+ total = 0
85
+ with output_path.open("wb") as destination:
86
+ while True:
87
+ chunk = response.read(8192)
88
+ if not chunk:
89
+ break
90
+ total += len(chunk)
91
+ if total > maximum_bytes:
92
+ raise ValueError("Download exceeds the 128 MiB limit")
93
+ destination.write(chunk)
94
+ response.close()
95
+ return total
52
96
 
53
97
 
54
98
  def get_api_key() -> str:
@@ -120,7 +164,7 @@ def download_slides_pages_voices(
120
164
  download_url = data.get("downloadUrl")
121
165
  if not download_url:
122
166
  raise ValueError("No download URL in response")
123
- download_url = validate_public_https_url(download_url)
167
+ download_url, pinned_ip = validate_public_https_url(download_url)
124
168
 
125
169
  # Optional: log additional info
126
170
  file_name = data.get("fileName", "unknown.zip")
@@ -134,15 +178,16 @@ def download_slides_pages_voices(
134
178
 
135
179
  print(f"Downloading ZIP archive to: {output_path}...", file=sys.stderr)
136
180
 
137
- zip_response = requests.get(download_url, stream=True, timeout=120)
138
- zip_response.raise_for_status()
139
-
140
- # Save to file
141
- with safe_user_path(output_path).open('wb') as f:
142
- for chunk in zip_response.iter_content(chunk_size=8192):
143
- f.write(chunk)
144
-
145
- file_size = os.path.getsize(output_path)
181
+ destination = safe_user_path(output_path)
182
+ destination.parent.mkdir(parents=True, exist_ok=True)
183
+ with tempfile.NamedTemporaryFile(prefix=".2slides-", suffix=".zip", dir=destination.parent, delete=False) as tmp:
184
+ temporary = Path(tmp.name)
185
+ try:
186
+ file_size = download_pinned_https(download_url, pinned_ip, temporary)
187
+ os.chmod(temporary, 0o600)
188
+ os.replace(temporary, destination)
189
+ finally:
190
+ temporary.unlink(missing_ok=True)
146
191
  print(f"✓ Downloaded successfully!", file=sys.stderr)
147
192
  print(f" File: {output_path}", file=sys.stderr)
148
193
  print(f" Size: {file_size:,} bytes", file=sys.stderr)
@@ -125,6 +125,7 @@ Every stable or prerelease version requires full release alignment. Creating the
125
125
  4. Run `npm run release:prepare -- X.Y.Z`. This creates and pushes `release/vX.Y.Z` and opens the protected release PR.
126
126
  5. Merge that release PR through its required checks, update local `main` to equal `origin/main`, and wait for every source, release, or canonical-sync PR in the release path to close. Re-run the release-state and plugin gates if protected `main` moved.
127
127
  6. Run `npm run release:publish -- X.Y.Z`. It must resolve exactly one merged release PR from the same repository, authored by the repository owner, with base `main`, exact title `chore: release vX.Y.Z`, and head branch `release/vX.Y.Z`. Zero or multiple candidates fail closed; never select the newest approximate match. The command then verifies that exact protected merge before creating or reusing the tag and GitHub Release.
128
+ The npm publication workflow must first check out protected `main`, verify that the peeled release tag is an ancestor of current `origin/main`, validate the version directly from the tagged `package.json`, and only then check out or execute tag-controlled code. It has no manual-dispatch bypass.
128
129
  7. Wait for publishing workflows, then bind every proof to the exact released commit: verify the tag/ref, GitHub Release, npm version and intended dist-tag, required CI, CodeQL, and the explicitly dispatched release-only Pages build from the exact immutable `vX.Y.Z` tag. Never dispatch Pages from `main` or another branch. Verify live `llms.txt`, `skills.json`, catalog and plugin routes, and the legacy redirect bridge; do not accept a successful run for a different SHA.
129
130
  8. After npm confirms `X.Y.Z` as the published dist-tag, discover every already-configured local AAS MCP host from its real configuration and update each one to the exact same package version before declaring the release complete. Updating existing AAS host entries is part of the release; creating a previously absent host configuration still requires explicit authorization.
130
131
  - Use the published package's `aas mcp configure` two-pass flow: first preview the change, then repeat the identical command with its approval digest. Supply absolute host-config, cache, and backup paths; require a backup when replacing an existing configuration.
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -176,6 +176,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
176
176
  }
177
177
  }
178
178
 
179
+ function csvCell(value) {
180
+ if (value === null || value === undefined) return '';
181
+ let text = String(value);
182
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
183
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
184
+ }
185
+
179
186
  // Download dataset items
180
187
  async function downloadResults(token, datasetId, outputPath, format) {
181
188
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -200,7 +207,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
200
207
  // CSV output
201
208
  if (data.length > 0) {
202
209
  const fieldnames = Object.keys(data[0]);
203
- const csvLines = [fieldnames.join(',')];
210
+ const csvLines = [fieldnames.map(csvCell).join(',')];
204
211
 
205
212
  for (const row of data) {
206
213
  const values = fieldnames.map((key) => {
@@ -213,15 +220,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
213
220
  value = JSON.stringify(value) || '';
214
221
  }
215
222
 
216
- // CSV escape: wrap in quotes if contains comma, quote, or newline
217
- if (value === null || value === undefined) {
218
- return '';
219
- }
220
- const strValue = String(value);
221
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
222
- return `"${strValue.replace(/"/g, '""')}"`;
223
- }
224
- return strValue;
223
+ return csvCell(value);
225
224
  });
226
225
  csvLines.push(values.join(','));
227
226
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -170,6 +170,13 @@ async function pollUntilComplete(token, runId, timeout, interval) {
170
170
  }
171
171
  }
172
172
 
173
+ function csvCell(value) {
174
+ if (value === null || value === undefined) return '';
175
+ let text = String(value);
176
+ if (/^[\u0000-\u0020]*[=+@-]/.test(text)) text = `'${text}`;
177
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
178
+ }
179
+
173
180
  // Download dataset items
174
181
  async function downloadResults(token, datasetId, outputPath, format) {
175
182
  const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
@@ -194,7 +201,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
194
201
  // CSV output
195
202
  if (data.length > 0) {
196
203
  const fieldnames = Object.keys(data[0]);
197
- const csvLines = [fieldnames.join(',')];
204
+ const csvLines = [fieldnames.map(csvCell).join(',')];
198
205
 
199
206
  for (const row of data) {
200
207
  const values = fieldnames.map((key) => {
@@ -207,15 +214,7 @@ async function downloadResults(token, datasetId, outputPath, format) {
207
214
  value = JSON.stringify(value) || '';
208
215
  }
209
216
 
210
- // CSV escape: wrap in quotes if contains comma, quote, or newline
211
- if (value === null || value === undefined) {
212
- return '';
213
- }
214
- const strValue = String(value);
215
- if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
216
- return `"${strValue.replace(/"/g, '""')}"`;
217
- }
218
- return strValue;
217
+ return csvCell(value);
219
218
  });
220
219
  csvLines.push(values.join(','));
221
220
  }
@@ -247,6 +247,22 @@ function escapeHtml(str) {
247
247
  return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
248
248
  }
249
249
 
250
+ function safeHttpUrl(value) {
251
+ try {
252
+ const parsed = new URL(String(value || '').trim());
253
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
254
+ } catch {
255
+ return '';
256
+ }
257
+ }
258
+
259
+ function externalLink(url, label, extra = '') {
260
+ const safe = safeHttpUrl(url);
261
+ return safe
262
+ ? `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer"${extra}>${escapeHtml(label || safe)}</a>`
263
+ : escapeHtml(label || url || '');
264
+ }
265
+
250
266
  function mdToHtml(md) {
251
267
  const lines = md.split('\n');
252
268
  const out = [];
@@ -283,7 +299,7 @@ function mdToHtml(md) {
283
299
  url = url.slice(0, -1);
284
300
  }
285
301
  if (!url) return raw;
286
- return `<a href="${url}" target="_blank">${url}</a>${trail}`;
302
+ return `${externalLink(url, url)}${trail}`;
287
303
  });
288
304
  out.push(`<li>${text}</li>`);
289
305
  continue;
@@ -394,7 +410,7 @@ const tableRows = competitorRows.map(c => {
394
410
  ? `<a href="competitors/${c.slug}.html">${escapeHtml(c.competitor_name)}</a>`
395
411
  : escapeHtml(c.competitor_name);
396
412
  const websiteHtml = c.website
397
- ? `<span class="muted-line"><a href="${escapeHtml(c.website)}" target="_blank" style="color:var(--muted);">${escapeHtml(c.website.replace(/^https?:\/\/(www\.)?/, ''))}</a></span>`
413
+ ? `<span class="muted-line">${externalLink(c.website, c.website.replace(/^https?:\/\/(www\.)?/, ''), ' style="color:var(--muted);"')}</span>`
398
414
  : '';
399
415
  // Pricing: prefer pipe-split summary; if there are no pipes (prose drift), truncate hard.
400
416
  let pricingShort = splitPipes(c.pricing_tiers).slice(0, 3).join(' · ');
@@ -573,7 +589,7 @@ for (const c of competitorRows) {
573
589
  const mentionsHtml = c.mentions.length
574
590
  ? c.mentions.map(m => {
575
591
  const dateStr = m.date ? `<span class="muted-line" style="color:var(--muted);font-size:0.75rem;margin-left:auto;">${escapeHtml(m.date)}</span>` : '';
576
- const linkText = m.url ? `<a href="${escapeHtml(m.url)}" target="_blank">${escapeHtml(m.title || m.url)}</a>` : escapeHtml(m.title);
592
+ const linkText = m.url ? externalLink(m.url, m.title || m.url) : escapeHtml(m.title);
577
593
  const snippet = m.snippet ? ` — <span style="color:var(--muted);">${escapeHtml(m.snippet)}</span>` : '';
578
594
  return `<div class="mention-item"><span class="src-pill src-${escapeHtml(m.sourceType)}">${escapeHtml(m.sourceType)}</span><div style="flex:1;">${linkText}${snippet}</div>${dateStr}</div>`;
579
595
  }).join('\n')
@@ -581,7 +597,7 @@ for (const c of competitorRows) {
581
597
 
582
598
  const benchmarksHtml = c.benchmarks.length
583
599
  ? `<ul>${c.benchmarks.map(b => {
584
- const link = b.url ? `<a href="${escapeHtml(b.url)}" target="_blank">${escapeHtml(b.title || b.url)}</a>` : escapeHtml(b.title);
600
+ const link = b.url ? externalLink(b.url, b.title || b.url) : escapeHtml(b.title);
585
601
  const src = b.source ? ` <span style="color:var(--muted);">(${escapeHtml(b.source)})</span>` : '';
586
602
  const finding = b.finding ? ` — ${escapeHtml(b.finding)}` : '';
587
603
  return `<li>${link}${src}${finding}</li>`;
@@ -622,7 +638,7 @@ for (const c of competitorRows) {
622
638
  <header>
623
639
  <h1>${escapeHtml(c.competitor_name)}</h1>
624
640
  <div class="meta">
625
- ${c.website ? `<a href="${escapeHtml(c.website)}" target="_blank">${escapeHtml(c.website)}</a>` : ''}
641
+ ${c.website ? externalLink(c.website, c.website) : ''}
626
642
  ${c.tagline ? ` · ${escapeHtml(c.tagline)}` : ''}
627
643
  </div>
628
644
  </header>${screenshotsHtml}
@@ -838,7 +854,7 @@ const sourceFilterButtons = ['All', ...sourceTypes].map(t =>
838
854
  ).join('');
839
855
 
840
856
  const mentionItems = allMentions.map(m => {
841
- const link = m.url ? `<a href="${escapeHtml(m.url)}" target="_blank">${escapeHtml(m.title || m.url)}</a>` : escapeHtml(m.title);
857
+ const link = m.url ? externalLink(m.url, m.title || m.url) : escapeHtml(m.title);
842
858
  const snippet = m.snippet ? `<div class="snippet">${escapeHtml(m.snippet)}</div>` : '';
843
859
  const date = m.date ? `<span class="date">${escapeHtml(m.date)}</span>` : '';
844
860
  return `<div class="mention" data-type="${escapeHtml(m.sourceType)}">