diff --git a/public/arf.json b/public/arf.json index f26d28d..4f930c6 100644 --- a/public/arf.json +++ b/public/arf.json @@ -10,6 +10,26 @@ "name": "Username Search Engines", "type": "folder", "children": [ + { + "name": "WhatsMyName (T)", + "type": "url", + "url": "https://github.com/WebBreacher/WhatsMyName", + "description": "OSINT project maintaining a curated JSON database of website detection patterns for username enumeration. Web interface available at whatsmyname.app.", + "status": "live", + "pricing": "free", + "bestFor": "Username enumeration using community-maintained site detection data", + "input": "Username", + "output": "List of sites where the username exists, based on HTTP response pattern matching", + "opsec": "active", + "opsecNote": "Makes HTTP requests to each target site to check for username existence.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, { "name": "Sylva Identity Discovery (T)", "type": "url", @@ -70,26 +90,6 @@ "invitationOnly": false, "deprecated": false }, - { - "name": "WhatsMyName (T)", - "type": "url", - "url": "https://github.com/WebBreacher/WhatsMyName", - "description": "OSINT project maintaining a curated JSON database of website detection patterns for username enumeration. Web interface available at whatsmyname.app.", - "status": "live", - "pricing": "free", - "bestFor": "Username enumeration using community-maintained site detection data", - "input": "Username", - "output": "List of sites where the username exists, based on HTTP response pattern matching", - "opsec": "active", - "opsecNote": "Makes HTTP requests to each target site to check for username existence.", - "localInstall": false, - "googleDork": false, - "registration": false, - "editUrl": false, - "api": false, - "invitationOnly": false, - "deprecated": false - }, { "name": "Thats Them", "type": "url", @@ -2696,8 +2696,8 @@ "name": "Fedifinder", "type": "url", "url": "https://fedifinder.glitch.me/", - "description": "Web tool that scans your Twitter/X following list to find accounts that have migrated to the Fediverse by checking bios and pinned tweets for Mastodon handles.", - "status": "live", + "description": "Web tool that scanned Twitter profiles to find Fediverse/Mastodon handles among your contacts and exported them as CSV. The hosted instance now returns HTTP 410 and is no longer functional due to Twitter API restrictions.", + "status": "down", "pricing": "free", "bestFor": "Finding Twitter contacts who moved to Mastodon/Fediverse", "input": "Twitter/X account (via OAuth login)", @@ -2710,7 +2710,7 @@ "editUrl": false, "api": false, "invitationOnly": false, - "deprecated": false + "deprecated": true }, { "name": "Fediverse Observer", @@ -2735,9 +2735,9 @@ { "name": "Fediverse_OSINT (T)", "type": "url", - "url": "https://github.com/anantshri/fediverse_osint", - "description": "Open-source tool for searching and gathering intelligence across multiple Fediverse platforms including Mastodon, Pleroma, and Misskey instances.", - "status": "live", + "url": "https://github.com/cyfinoid/fediverse_osint", + "description": "Python CLI tool for checking whether a domain belongs to the Fediverse and hunting usernames across discoverable Fediverse servers.", + "status": "degraded", "pricing": "free", "bestFor": "Cross-instance Fediverse user and content search", "input": "Username or search terms", @@ -3037,8 +3037,8 @@ "name": "TikTok-OSINT (T)", "type": "url", "url": "https://github.com/Omicron166/TikTok-OSINT", - "description": "Python CLI tool for collecting OSINT data from TikTok profiles including user metadata, video listings, and engagement statistics.", - "status": "live", + "description": "Python CLI tool for collecting OSINT data from TikTok profiles including user metadata, video listings, and engagement statistics. Last updated October 2022.", + "status": "degraded", "pricing": "free", "bestFor": "Automated TikTok profile data collection", "input": "TikTok username or profile URL", @@ -3057,15 +3057,15 @@ "name": "Unfurl", "type": "url", "url": "https://dfir.blog/unfurl/", - "description": "Web-based tool that extracts and visualizes metadata embedded in URLs, including TikTok share links, timestamps, and tracking parameters.", + "description": "Web and CLI tool that extracts and visualizes metadata embedded in URLs, including TikTok share links, timestamps, and tracking parameters. Also installable locally via Python.", "status": "live", "pricing": "free", "bestFor": "URL metadata extraction and timestamp analysis", "input": "Any URL (including TikTok share/video links)", "output": "Visual graph of extracted URL components, timestamps, and embedded identifiers", "opsec": "passive", - "opsecNote": "Parses URL structure locally in browser or on the Unfurl server; does not contact the target platform.", - "localInstall": false, + "opsecNote": "Parses URL structure locally in browser or on the Unfurl server; does not contact the target platform. Local install available for sensitive investigations.", + "localInstall": true, "googleDork": false, "registration": false, "editUrl": false, @@ -3106,6 +3106,92 @@ } ] }, + { + "name": "Threads", + "type": "folder", + "children": [ + { + "name": "Bellingcat Meta Content Library", + "type": "url", + "url": "https://bellingcat.gitbook.io/toolkit/more/all-tools/meta-content-library", + "description": "Bellingcat's guide to Meta's Content Library, a research platform providing near-real-time searchable archive of public Threads, Facebook, and Instagram content.", + "status": "live", + "pricing": "free", + "bestFor": "Large-scale Threads content search for academic and non-profit research", + "input": "Search queries, date ranges, content filters", + "output": "Public posts, engagement metrics, and text-in-image search results from Threads and other Meta platforms", + "opsec": "passive", + "opsecNote": "Searches Meta's research archive; does not interact with target accounts directly. Requires academic/non-profit eligibility approval.", + "localInstall": false, + "googleDork": false, + "registration": true, + "editUrl": false, + "api": true, + "invitationOnly": true, + "deprecated": false + }, + { + "name": "Threads Dashboard", + "type": "url", + "url": "https://www.threadsdashboard.com/", + "description": "Analytics and insights platform for Threads accounts using the official API. Tracks audience demographics, engagement metrics, and historical posting data.", + "status": "live", + "pricing": "freemium", + "bestFor": "Threads account analytics and engagement investigation", + "input": "Threads username or account URL", + "output": "Audience demographics, engagement rates, posting frequency, optimal posting times, and historical data", + "opsec": "passive", + "opsecNote": "Retrieves data via Meta's official Threads API; no direct contact with the target account.", + "localInstall": false, + "googleDork": false, + "registration": true, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "Threads-Scraper (T)", + "type": "url", + "url": "https://github.com/Zeeshanahmad4/Threads-Scraper", + "description": "Python browser automation tool that scrapes public Threads posts and profiles without authentication, outputting structured data in JSON, CSV, or XML. Last updated July 2023.", + "status": "degraded", + "pricing": "free", + "bestFor": "Bulk extraction of Threads posts for offline analysis", + "input": "Threads profile URL or post URL", + "output": "Extracted posts in JSON, CSV, or XML format with metadata", + "opsec": "active", + "opsecNote": "Scrapes Threads directly; depends on site structure and may trigger rate limiting.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "ThreadsRecon (T)", + "type": "url", + "url": "https://github.com/offseq/threadsrecon", + "description": "Python OSINT tool for Threads profile analysis including sentiment analysis, network visualization, and automated PDF reporting.", + "status": "live", + "pricing": "free", + "bestFor": "Threads profile investigation with sentiment and network analysis", + "input": "Threads username", + "output": "Profile analysis, sentiment scores, network graphs, and PDF investigation reports", + "opsec": "active", + "opsecNote": "Makes direct requests to Threads to collect profile and post data for analysis.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + } + ] + }, { "name": "Steam, Discord & Gaming Networks", "type": "folder", @@ -5420,12 +5506,42 @@ { "name": "Live Journal Seek", "type": "url", - "url": "https://ljseek.com/" + "url": "https://ljseek.com/", + "description": "Search tool for LiveJournal journals and communities across public entries that are indexed.", + "status": "live", + "pricing": "free", + "bestFor": "Finding public LiveJournal entries and historical community discussions", + "input": "Keywords and search terms", + "output": "Matching journal entries with author, date, and snippets", + "opsec": "passive", + "opsecNote": "Searches publicly indexed content and does not contact journal owners directly; queries are likely logged.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Blog Search Engine", "type": "url", - "url": "https://www.blogsearchengine.org/" + "url": "https://www.blogsearchengine.org/", + "description": "Blog-focused search engine for discovering blog posts, directories, and RSS-connected content.", + "status": "live", + "pricing": "free", + "bestFor": "Blog discovery and topic-focused blog post searching", + "input": "Keywords and blog topics", + "output": "Matching blog posts, listings, and feed-linked results", + "opsec": "passive", + "opsecNote": "Aggregates publicly indexed blog data without direct user contact to target blogs; queries are logged.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5436,17 +5552,62 @@ { "name": "Discord Bot List", "type": "url", - "url": "https://discord.bots.gg/" + "url": "https://discord.bots.gg/", + "description": "Searchable directory of Discord bots with listings, categories, and discovery metadata.", + "status": "live", + "pricing": "free", + "bestFor": "Discord bot discovery and ecosystem mapping", + "input": "Bot names, keywords, and categories", + "output": "Bot profiles with descriptions, ratings, and invite metadata", + "opsec": "passive", + "opsecNote": "Enumerates publicly listed bots and does not require direct Discord account interaction for browsing.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "ReconXplorer (T)", "type": "url", - "url": "https://github.com/root7am/ReconXplorer" + "url": "https://github.com/root7am/ReconXplorer", + "description": "Open-source reconnaissance toolkit with modules for IP, email, and Discord-focused lookups.", + "status": "live", + "pricing": "free", + "bestFor": "Multi-input OSINT checks from a local scriptable toolkit", + "input": "IP addresses, emails, Discord tokens, and host data", + "output": "Recon results including geolocation, service, and account-related metadata", + "opsec": "active", + "opsecNote": "Runs direct external queries from the operator environment and may trigger service-side detection or logging.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "Top.gg", "type": "url", - "url": "https://top.gg/" + "url": "https://top.gg/", + "description": "Large Discord bot and app discovery platform with ranking, filtering, and listing data.", + "status": "live", + "pricing": "free", + "bestFor": "Discord bot ranking analysis and app discovery", + "input": "Bot names, tags, and search filters", + "output": "Bot listings with popularity, server counts, and profile details", + "opsec": "passive", + "opsecNote": "Uses public listing data and does not require direct target interaction for standard browsing.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5457,27 +5618,102 @@ { "name": "BoardReader", "type": "url", - "url": "https://boardreader.com/" + "url": "https://boardreader.com/", + "description": "Forum search engine that indexes discussions across message boards and community platforms.", + "status": "live", + "pricing": "free", + "bestFor": "Finding forum threads and topic-centric discussion history", + "input": "Keywords, forum names, and topical queries", + "output": "Indexed posts and thread references with source links", + "opsec": "passive", + "opsecNote": "Searches indexed forum content and avoids direct interaction with target forum users.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Omgili", "type": "url", - "url": "https://webz.io/" + "url": "https://webz.io/", + "description": "Forum and discussion search capability operated through Webz.io infrastructure and data products.", + "status": "live", + "pricing": "freemium", + "bestFor": "Forum discussion discovery with optional API-driven workflows", + "input": "Keywords and Boolean-style forum queries", + "output": "Discussion posts and thread-level matching results", + "opsec": "passive", + "opsecNote": "Uses indexed discussion datasets from a third-party provider; user-side browsing is passive.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "Craigslist Forums", "type": "url", - "url": "https://forums.craigslist.org/" + "url": "https://forums.craigslist.org/", + "description": "Craigslist-hosted forum system for public community discussions and region-oriented threads.", + "status": "live", + "pricing": "free", + "bestFor": "Reviewing Craigslist community discussions and regional forum activity", + "input": "Forum categories, keywords, and regional navigation", + "output": "Forum threads, post content, and timing context", + "opsec": "passive", + "opsecNote": "Reads publicly visible forum content and does not require direct messaging or contact with users.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Delphi Forum Search", "type": "url", - "url": "https://www.delphiforums.com/" + "url": "https://www.delphiforums.com/", + "description": "Forum platform with searchable user communities across niche interest categories.", + "status": "live", + "pricing": "freemium", + "bestFor": "Niche forum discovery and historical community thread review", + "input": "Forum names, categories, and keywords", + "output": "Forum listings, thread titles, and message pages", + "opsec": "passive", + "opsecNote": "Public forum browsing is passive; some communities may require registration for deeper access.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Google Groups Search", "type": "url", - "url": "https://groups.google.com/forum/#!overview" + "url": "https://groups.google.com/forum/#!overview", + "description": "Search interface for Google Groups and archived discussion content, including historical threads.", + "status": "live", + "pricing": "free", + "bestFor": "Researching archived mailing-list and discussion-group content", + "input": "Keywords, group names, authors, and date ranges", + "output": "Thread messages and group-level discussion results", + "opsec": "passive", + "opsecNote": "Searches archived public discussions through Google-hosted infrastructure where activity is logged.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": true, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5488,22 +5724,82 @@ { "name": "Mibbit", "type": "url", - "url": "https://search.mibbit.com/" + "url": "https://search.mibbit.com/", + "description": "Former web IRC client and channel search service that is no longer operational.", + "status": "down", + "pricing": "free", + "bestFor": "Legacy reference for historical IRC channel search workflows", + "input": "Channel or keyword queries (historical behavior)", + "output": "No current output; service is discontinued", + "opsec": "Unknown", + "opsecNote": "Service shut down on August 30, 2024 and cannot be relied on for active workflows.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": true }, { "name": "IRCP (T)", "type": "url", - "url": "https://github.com/internet-relay-chat/IRCP" + "url": "https://github.com/internet-relay-chat/IRCP", + "description": "Python-based IRC probing utility for scanning servers and collecting network/channel metadata.", + "status": "live", + "pricing": "free", + "bestFor": "IRC server enumeration and protocol-level reconnaissance", + "input": "Target ranges, IRC ports, and server parameters", + "output": "Server and channel metadata in machine-readable output", + "opsec": "active", + "opsecNote": "Actively connects to IRC services and may be detectable by network monitoring and server logs.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "ircsnapshot (T)", "type": "url", - "url": "https://github.com/bwall/ircsnapshot" + "url": "https://github.com/bwall/ircsnapshot", + "description": "IRC data collection tool that connects bots to servers for user and channel mapping.", + "status": "live", + "pricing": "free", + "bestFor": "IRC topology mapping and user/channel relationship analysis", + "input": "IRC server details, bot config, and channel targets", + "output": "Collected user, hostmask, and channel affiliation data", + "opsec": "active", + "opsecNote": "Requires active network participation via bot accounts and is likely visible to channel operators.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "netsplit.de", "type": "url", - "url": "https://netsplit.de/channels/search.php" + "url": "https://netsplit.de/channels/search.php", + "description": "IRC directory and search portal for channel listings, network stats, and discovery workflows.", + "status": "live", + "pricing": "free", + "bestFor": "Passive IRC channel discovery and network trend checks", + "input": "Channel names, keywords, and network filters", + "output": "Channel listings with network and user-count context", + "opsec": "passive", + "opsecNote": "Uses aggregated indexed IRC data and does not require direct IRC server interaction for searches.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5514,17 +5810,62 @@ { "name": "Arctic Shift", "type": "url", - "url": "https://arctic-shift.photon-reddit.com/" + "url": "https://arctic-shift.photon-reddit.com/", + "description": "Search and access layer for Reddit datasets with tools for historical content retrieval and analysis.", + "status": "live", + "pricing": "freemium", + "bestFor": "Historical Reddit dataset analysis and subreddit research", + "input": "Search terms, dataset queries, or API-style requests", + "output": "Matching Reddit posts, comments, and metadata from indexed datasets", + "opsec": "passive", + "opsecNote": "Works against archived or indexed data sources without requiring direct engagement with target accounts.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "Cama's Reddit Search", "type": "url", - "url": "https://camas.github.io/reddit-search/" + "url": "https://camas.github.io/reddit-search/", + "description": "Web tool for searching Reddit posts and comments by author, subreddit, text, and time filters.", + "status": "live", + "pricing": "free", + "bestFor": "Reddit user and subreddit content discovery", + "input": "Usernames, subreddits, keywords, and date constraints", + "output": "Filtered post/comment search results with metadata", + "opsec": "passive", + "opsecNote": "Uses public data indexing paths; original GitHub hosting was discontinued but alternative hosting remains available.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Reveddit", "type": "url", - "url": "https://www.reveddit.com/" + "url": "https://www.reveddit.com/", + "description": "Interface for viewing Reddit content removals using archived and moderation-related visibility signals.", + "status": "live", + "pricing": "free", + "bestFor": "Investigating deleted or removed Reddit discussions", + "input": "Reddit URLs, usernames, or subreddit paths", + "output": "Recovered or flagged removed-content views with moderation indicators", + "opsec": "passive", + "opsecNote": "Queries archive-backed sources and public content pathways without direct contact to target users.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false } ] } diff --git a/public/css/arf.css b/public/css/arf.css index 18153d4..2c52415 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -70,6 +70,8 @@ body { color: var(--color-text-primary); font-size: var(--font-size-base); font-family: var(--font-family); + margin: 0; + overflow: hidden; } noscript p { @@ -94,7 +96,8 @@ noscript p { svg { display: block; width: 100%; - height: auto; + /* Fill remaining viewport height below header + search bar */ + height: calc(100vh - 120px); cursor: grab; } @@ -103,20 +106,50 @@ svg:active { } #header { + display: flex; + align-items: center; + justify-content: center; + position: relative; font-size: var(--font-size-xl); font-weight: bold; - top: 20px; text-align: center; color: var(--color-header); + padding: var(--space-2) var(--space-4); +} + +#header-actions { + position: absolute; + right: var(--space-4); + top: 50%; + transform: translateY(-50%); + display: flex; + gap: var(--space-2); + align-items: center; +} + +#header-actions button { + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-family: var(--font-family); + padding: var(--space-1) var(--space-2); + cursor: pointer; + white-space: nowrap; +} + +#header-actions button:hover { + color: var(--color-text-primary); + border-color: var(--color-text-secondary); } .legend { - position: absolute; - top: 0px; - right: 0; - width: 330px; - font-size: var(--font-size-sm); - color: var(--color-legend); + display: none; +} + +#notes-content { + display: none; } .legend a { @@ -260,18 +293,158 @@ a { text-align: center; } +/* Mobile floating notes button (hidden on desktop; header button used instead) */ +#mobile-notes-btn { + display: none; +} + +/* Notes overlay */ +#notes-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 299; +} + +#notes-overlay.visible { + display: block; +} + +/* Notes panel (desktop: side panel) */ +#notes-panel { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + right: 0; + width: 400px; + max-width: 100vw; + height: 100vh; + background: var(--color-surface); + z-index: 300; + transform: translateX(100%); + transition: transform 250ms ease; + will-change: transform; + box-shadow: var(--shadow-md); +} + +#notes-panel.open { + transform: translateX(0); +} + +#notes-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +#notes-panel-title { + font-size: var(--font-size-lg); + font-weight: bold; + color: var(--color-text-primary); +} + +#notes-panel-close { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 2px var(--space-1); + border-radius: var(--radius-sm); +} + +#notes-panel-close:hover { + color: var(--color-text-primary); + background: var(--color-bg); +} + +#notes-panel-body { + flex: 1; + padding: var(--space-4); + overflow-y: auto; + font-size: var(--font-size-base); + color: var(--color-text-primary); + line-height: 1.6; +} + +#notes-panel-body a { + color: var(--color-node-stroke); +} + +#notes-panel-body h3 { + margin-top: var(--space-4); + margin-bottom: var(--space-2); +} + +#notes-panel-body h3:first-child { + margin-top: 0; +} + /* === Mobile responsive === */ @media (max-width: 768px) { #header { font-size: var(--font-size-lg); - top: 10px; + top: 0; + position: relative; + padding: var(--space-2) var(--space-3); } - .legend { - position: relative; - width: auto; - max-width: 100%; - padding: var(--space-2) var(--space-3); - box-sizing: border-box; + #header-actions { + right: var(--space-3); + } + + /* Hide desktop notes button on mobile (floating button used instead) */ + #header-notes-btn { + display: none; + } + + #search-container { + padding: var(--space-1) 0; + } + + #search-input { + padding: var(--space-1) var(--space-2); + font-size: 13px; + } + + /* Maximize SVG tree viewport */ + svg { + height: calc(100vh - 80px); + } + + /* Floating notes button (mobile only) */ + #mobile-notes-btn { + display: block; + position: fixed; + bottom: var(--space-4); + left: 50%; + transform: translateX(-50%); + background: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 20px; + padding: var(--space-2) var(--space-5); + font-size: var(--font-size-base); + font-family: var(--font-family); + font-weight: bold; + cursor: pointer; + box-shadow: var(--shadow-md); + z-index: 100; + } + + #mobile-notes-btn:hover { + background: var(--color-bg); + } + + /* Full-width notes panel on mobile */ + #notes-panel { + width: 100vw; + box-shadow: none; } } diff --git a/public/css/panel.css b/public/css/panel.css index 7128b1c..f623f76 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -11,6 +11,7 @@ #panel-overlay.visible { display: block; + pointer-events: none; } /* Panel container */ @@ -20,6 +21,7 @@ right: 0; width: 340px; height: 100vh; + height: 100dvh; background: var(--color-surface); border-left: 1px solid var(--color-border); box-shadow: var(--shadow-md); @@ -187,50 +189,122 @@ gap: var(--space-2); } -/* Community rating / vote */ +/* Community rating */ #panel-rating-section { display: flex; flex-direction: column; gap: var(--space-2); } -#panel-vote-row { +#panel-rating-row { display: flex; align-items: center; gap: var(--space-3); } -.vote-btn { +#star-rating { + display: flex; + align-items: center; + gap: 2px; +} + +.star-zero-btn { background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - padding: var(--space-1) var(--space-2); - font-size: 18px; + border: 1px solid var(--color-text-secondary); + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 11px; + font-weight: 600; cursor: pointer; + color: var(--color-text-secondary); line-height: 1; - transition: background 150ms ease, border-color 150ms ease; + display: flex; + align-items: center; + justify-content: center; + margin-right: 2px; + transition: color 100ms ease, border-color 100ms ease, transform 100ms ease; } -.vote-btn:hover { - background: var(--color-bg); - border-color: var(--color-text-secondary); +.star-zero-btn:hover { + transform: scale(1.15); + color: #e8960f; + border-color: #e8960f; } -.vote-btn.active { - background: var(--color-accent); - border-color: var(--color-accent); +.star-zero-btn.active { + color: #e8960f; + border-color: #e8960f; + background: rgba(232, 150, 15, 0.1); } -.vote-score { - font-size: var(--font-size-lg); - font-weight: bold; - min-width: 2ch; - text-align: center; +.star-pos { + position: relative; + display: inline-block; + cursor: pointer; } -.vote-score.positive { color: #2d9e2d; } -.vote-score.negative { color: #c84040; } -.vote-score.zero { color: var(--color-text-secondary); } +.star-icon { + font-size: 22px; + line-height: 1; + color: var(--color-text-secondary); + pointer-events: none; + transition: color 100ms ease, transform 100ms ease; +} + +.star-pos:hover .star-icon { + transform: scale(1.15); +} + +.star-icon.filled { + color: #f5a623; +} + +.star-icon.half-filled { + background: linear-gradient(to right, #f5a623 50%, var(--color-text-secondary) 50%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.star-icon.user-rated { + color: #e8960f; +} + +.star-icon.user-rated.half-filled { + background: linear-gradient(to right, #e8960f 50%, var(--color-text-secondary) 50%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.star-click { + position: absolute; + top: 0; + height: 100%; + background: none; + border: none; + cursor: pointer; + opacity: 0; + padding: 0; + margin: 0; +} + +.star-click.star-left { + left: 0; + width: 50%; +} + +.star-click.star-right { + left: 50%; + width: 50%; +} + +#rating-avg { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + white-space: nowrap; +} /* Footer (sticky, always visible) */ #panel-footer { @@ -335,7 +409,14 @@ .badge-active { background: #c84040; } /* Unknown / fallback */ -.badge-unknown { background: #606060; } +.badge-unknown { background: #606060; } + +/* Boolean feature badges */ +.badge-api { background: #1e90cc; } +.badge-invitation-only { background: #b87800; } +.badge-deprecated { background: #8b3030; } +.badge-nsfw { background: #9b2b9b; } +.badge-region { background: #2e7d32; } /* OPSEC row layout */ .opsec-row { @@ -378,9 +459,14 @@ #tool-panel { width: 100vw; height: 100vh; + height: 100dvh; top: 0; right: 0; border-left: none; border-radius: 0; } + + #panel-overlay.visible { + pointer-events: auto; + } } diff --git a/public/index.html b/public/index.html index 236bab2..c566702 100644 --- a/public/index.html +++ b/public/index.html @@ -26,8 +26,11 @@
@@ -78,7 +81,37 @@
- +
+
+ + + ★ + + + + + ★ + + + + + ★ + + + + + ★ + + + + + ★ + + + +
+ … +
@@ -103,28 +136,41 @@ R - Requires registration
M - Indicates a URL that contains the search term and the URL itself must be edited manually

-
-

Notes

- OSINT framework focused on gathering information from free tools or resources. The intention is to help people find free OSINT resources. Some of the sites included might require registration or offer more data for $$$, but you should be able to get at least a portion of the available information for no cost.
+
+

Notes

+ OSINT framework focused on gathering information from free tools or resources. The intention is to help people find free OSINT resources. Some of the sites included might require registration or offer more data for $$$, but you should be able to get at least a portion of the available information for no cost.
-

I originally created this framework with an information security point of view. Since then, the response from other fields and disciplines has been incredible. I would love to be able to include any other OSINT resources, especially from fields outside of infosec. Please let me know about anything that might be missing!

+

I originally created this framework with an information security point of view. Since then, the response from other fields and disciplines has been incredible. I would love to be able to include any other OSINT resources, especially from fields outside of infosec. Please let me know about anything that might be missing!

-

For Update Notifications

- Follow me on Twitter: @jnordine
- Watch or star the project on Github: https://github.com/lockfale/osint-framework +

For Update Notifications

+ Follow me on Twitter: @jnordine
+ Watch or star the project on Github: https://github.com/lockfale/osint-framework -

Suggestions, Comments, Feedback

- Feedback or new tool suggestions are extremely welcome! Please feel free to reach out on Twitter or submit an issue on Github. +

Suggestions, Comments, Feedback

+ Feedback or new tool suggestions are extremely welcome! Please feel free to reach out on Twitter or submit an issue on Github. +
+ + + + + +
+
+
+ Notes + +
+
+
diff --git a/public/js/arf.js b/public/js/arf.js index a935de5..4b08769 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -83,6 +83,37 @@ d3.json("arf.json").then(function(json) { } root.children.forEach(collapse); + + // Stretch the viewBox to match the actual rendered aspect ratio so the + // zoom-to-fill calculation uses the real visible area, not the fixed + // 1280x800 letterboxed region. + var rect = svgEl.node().getBoundingClientRect(); + if (rect.width && rect.height) { + svgH = Math.round(svgW * (rect.height / rect.width)); + svgEl.attr("viewBox", "0 0 " + svgW + " " + svgH); + } + + // Run tree layout to get final node positions, then compute zoom from data. + tree(root); + root.descendants().forEach(function(d) { d.y = d.depth * 180; }); + var visibleNodes = root.descendants(); + var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + visibleNodes.forEach(function(d) { + if (d.x < minX) minX = d.x; + if (d.x > maxX) maxX = d.x; + if (d.y < minY) minY = d.y; + if (d.y > maxY) maxY = d.y; + }); + var pad = 40; + var bw = (maxY - minY) || 1; + var bh = (maxX - minX) || 1; + var k = Math.min((svgW - pad * 2) / bw, (svgH - pad * 2) / bh, 3); + var cx = (minY + maxY) / 2; + var cy = (minX + maxX) / 2; + var tx = pad - minY * k; + var ty = svgH / 2 - margin[0] - cy * k; + svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); + update(root); initSearch(); }); @@ -235,6 +266,19 @@ function toggle(d) { } } +// Zoom the tree so visible nodes fill the SVG viewport (used on mobile init). +function zoomToFill() { + var bbox = vis.node().getBBox(); + if (!bbox.width || !bbox.height) return; + var pad = 40; + var scaleX = (svgW - pad * 2) / bbox.width; + var scaleY = (svgH - pad * 2) / bbox.height; + var k = Math.min(scaleX, scaleY, 3); + var tx = svgW / 2 - (bbox.x + bbox.width / 2) * k - margin[3]; + var ty = svgH / 2 - (bbox.y + bbox.height / 2) * k - margin[0]; + svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); +} + // Auto-pan viewport to center on a node after expand/click. function zoomToNode(d) { var currentK = d3.zoomTransform(svgEl.node()).k; @@ -451,6 +495,30 @@ function openPanel(d) { var pricingClass = ["free","freemium","paid"].indexOf(pricing) !== -1 ? pricing : "unknown"; badgeHtml += ' ' + escapeHtml(pricing) + ''; + if (d.data.api === true) { + badgeHtml += ' API'; + } + if (d.data.invitationOnly === true) { + badgeHtml += ' Invite Only'; + } + if (d.data.deprecated === true) { + badgeHtml += ' Deprecated'; + } + if (d.data.nsfw === true) { + badgeHtml += ' NSFW'; + } + + var region = d.data.region; + var regionLabel; + if (!region || region === "global") { + regionLabel = "Global"; + } else if (Array.isArray(region)) { + regionLabel = region.map(function(r) { return r.toUpperCase(); }).join(", "); + } else { + regionLabel = String(region).toUpperCase(); + } + badgeHtml += ' ' + escapeHtml(regionLabel) + ''; + badgesEl.innerHTML = badgeHtml; badgesEl.classList.remove("empty"); } @@ -584,98 +652,154 @@ function closePanel() { if (root) update(root); } -// Keyboard: Escape closes panel +// Keyboard: Escape closes panels document.addEventListener("keydown", function(e) { - if (e.key === "Escape") closePanel(); + if (e.key === "Escape") { + var notesPanel = document.getElementById("notes-panel"); + if (notesPanel && notesPanel.classList.contains("open")) { + toggleNotesPanel(); + } else { + closePanel(); + } + } }); -// Wire close button and overlay once DOM is ready +// Wire close button once DOM is ready document.addEventListener("DOMContentLoaded", function() { var closeBtn = document.getElementById("panel-close"); if (closeBtn) closeBtn.addEventListener("click", closePanel); - - var overlay = document.getElementById("panel-overlay"); - if (overlay) overlay.addEventListener("click", closePanel); }); // Canvas click: close panel when clicking the SVG background (not a node) // This is wired after svgEl is created (see below in the zoom setup area). -// === Community Voting (THE-109) === +// === Community Rating (THE-122) === /** - * Render the vote UI for the given node. - * Reads cached vote state from sessionStorage to avoid a round-trip on reopen, - * then asynchronously fetches the live score from /api/tool-stats. + * Render the half-star rating UI for the given node. + * Supports 0 to 5 in 0.5 increments. Reads cached rating from sessionStorage, + * then fetches live average from /api/tool-stats. */ function _renderVoteUI(d) { var toolId = parseName(d.data.name).cleanName; - // Reset button states - var upBtn = document.getElementById("vote-up"); - var downBtn = document.getElementById("vote-down"); - var scoreEl = document.getElementById("vote-score"); - if (!upBtn || !downBtn || !scoreEl) return; + var starPositions = document.querySelectorAll("#star-rating .star-pos"); + var zeroBtn = document.querySelector("#star-rating .star-zero-btn"); + var avgEl = document.getElementById("rating-avg"); + var ratingSection = document.getElementById("panel-rating-section"); + if (!starPositions.length || !avgEl) return; - upBtn.classList.remove("active"); - downBtn.classList.remove("active"); - scoreEl.className = "vote-score zero"; - scoreEl.textContent = "…"; + // Reset state + _applyStarFill(starPositions, zeroBtn, 0, null); + avgEl.textContent = "\u2026"; + if (ratingSection) ratingSection.classList.remove("empty"); - // Read cached user vote from sessionStorage - var userVote = sessionStorage.getItem("vote:" + toolId) || null; - if (userVote === "up") upBtn.classList.add("active"); - if (userVote === "down") downBtn.classList.add("active"); + // Read cached user rating from sessionStorage + var cached = sessionStorage.getItem("rating:" + toolId); + var userRating = cached !== null ? parseFloat(cached) : null; + if (userRating !== null) _applyStarFill(starPositions, zeroBtn, userRating, userRating); - // Re-bind vote buttons for this tool - upBtn.onclick = function() { _castVote(toolId, "up", upBtn, downBtn, scoreEl); }; - downBtn.onclick = function() { _castVote(toolId, "down", upBtn, downBtn, scoreEl); }; + // Wire hover and click for zero button + if (zeroBtn) { + zeroBtn.onmouseenter = function() { _applyStarFill(starPositions, zeroBtn, 0, userRating); }; + zeroBtn.onmouseleave = function() { + _applyStarFill(starPositions, zeroBtn, userRating !== null ? userRating : -1, userRating); + }; + zeroBtn.onclick = function() { + userRating = _castRating(toolId, 0, userRating, starPositions, zeroBtn, avgEl); + }; + } - // Fetch live score asynchronously + // Wire hover and click for each half-star click zone + var clickZones = document.querySelectorAll("#star-rating .star-click"); + Array.prototype.forEach.call(clickZones, function(btn) { + var val = parseFloat(btn.getAttribute("data-value")); + + btn.onmouseenter = function() { _applyStarFill(starPositions, zeroBtn, val, userRating); }; + btn.onmouseleave = function() { + _applyStarFill(starPositions, zeroBtn, userRating !== null ? userRating : -1, userRating); + }; + btn.onclick = function() { + userRating = _castRating(toolId, val, userRating, starPositions, zeroBtn, avgEl); + }; + }); + + // Fetch live average asynchronously fetch("/api/tool-stats?tool_id=" + encodeURIComponent(toolId)) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { - if (!data || !data.votes) return; - _updateScoreDisplay(scoreEl, data.votes.score); + if (!data || !data.ratings) return; + _updateRatingDisplay(avgEl, data.ratings.average, data.ratings.count); }) .catch(function() { /* best effort */ }); } /** - * Cast or toggle a vote. + * Apply filled/half-filled/user-rated classes to star icons. + * @param {NodeList} starPositions - .star-pos elements + * @param {Element|null} zeroBtn - the 0-star button + * @param {number} fillUpTo - rating value to highlight up to (0-5, supports 0.5 steps; -1 = nothing) + * @param {number|null} userRating - the user's saved rating (shown distinctly) */ -function _castVote(toolId, direction, upBtn, downBtn, scoreEl) { - var session = sessionStorage.getItem("osint-session") || ""; - var currentVote = sessionStorage.getItem("vote:" + toolId) || null; +function _applyStarFill(starPositions, zeroBtn, fillUpTo, userRating) { + if (zeroBtn) { + zeroBtn.classList.toggle("active", userRating !== null && userRating === 0 && fillUpTo === 0); + } + Array.prototype.forEach.call(starPositions, function(pos) { + var n = parseInt(pos.getAttribute("data-star"), 10); + var icon = pos.querySelector(".star-icon"); + if (!icon) return; + var isUserStar = userRating !== null && fillUpTo === userRating; - // Toggle off if same direction - var newDirection = (direction === currentVote) ? null : direction; + icon.classList.remove("filled", "half-filled", "user-rated"); + if (fillUpTo >= n) { + icon.classList.add("filled"); + if (isUserStar) icon.classList.add("user-rated"); + } else if (fillUpTo >= n - 0.5) { + icon.classList.add("half-filled"); + if (isUserStar) icon.classList.add("user-rated"); + } + }); +} + +/** + * Cast or toggle a rating (0-5, 0.5 increments). + * Returns the new userRating value (number or null). + */ +function _castRating(toolId, value, currentRating, starPositions, zeroBtn, avgEl) { + var session = sessionStorage.getItem("osint-session") || ""; + // Toggle off if clicking the same value + var newRating = (value === currentRating) ? null : value; fetch("/api/vote", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool_id: toolId, direction: newDirection, session_hash: session }) + body: JSON.stringify({ tool_id: toolId, rating: newRating, session_hash: session }) }) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { if (!data || !data.ok) return; - // Update sessionStorage - if (data.userVote) { - sessionStorage.setItem("vote:" + toolId, data.userVote); + var saved = data.userRating; + if (saved !== null && saved !== undefined) { + sessionStorage.setItem("rating:" + toolId, String(saved)); } else { - sessionStorage.removeItem("vote:" + toolId); + saved = null; + sessionStorage.removeItem("rating:" + toolId); } - // Update button states - upBtn.classList.toggle("active", data.userVote === "up"); - downBtn.classList.toggle("active", data.userVote === "down"); - _updateScoreDisplay(scoreEl, data.score); + _applyStarFill(starPositions, zeroBtn, saved !== null ? saved : -1, saved); + _updateRatingDisplay(avgEl, data.average, data.count); }) .catch(function() { /* best effort */ }); + + return newRating; } -function _updateScoreDisplay(scoreEl, score) { - scoreEl.textContent = score > 0 ? "+" + score : String(score); - scoreEl.className = "vote-score " + (score > 0 ? "positive" : score < 0 ? "negative" : "zero"); +function _updateRatingDisplay(avgEl, average, count) { + if (!count || count === 0) { + avgEl.textContent = "No ratings yet"; + return; + } + avgEl.textContent = average.toFixed(1) + " (" + count + ")"; } // === Issue Reporting (THE-110) === @@ -741,10 +865,41 @@ function goDark() { var body = document.body; var isLight = body.classList.toggle("light-mode"); localStorage.setItem("theme", isLight ? "light" : "dark"); - var btn = document.getElementById("theme-toggle"); + var btn = document.getElementById("header-theme-toggle"); if (btn) { - btn.textContent = isLight ? "Switch to dark mode" : "Switch to light mode"; + btn.textContent = isLight ? "Dark Mode" : "Light Mode"; } // Re-render to pick up new CSS variable values for D3 inline styles. update(root); } + +// Notes panel toggle +function toggleNotesPanel() { + var panel = document.getElementById("notes-panel"); + var overlay = document.getElementById("notes-overlay"); + if (!panel) return; + + var isOpen = panel.classList.toggle("open"); + if (overlay) overlay.classList.toggle("visible", isOpen); + + // Populate panel body on first open + if (isOpen && !panel._populated) { + var body = document.getElementById("notes-panel-body"); + var source = document.getElementById("notes-content"); + var legend = document.querySelector(".legend"); + if (body) { + body.innerHTML = ""; + if (legend) body.innerHTML += legend.innerHTML; + if (source) body.innerHTML += source.innerHTML; + } + panel._populated = true; + } +} + +// Close notes panel when overlay is clicked +(function() { + var overlay = document.getElementById("notes-overlay"); + if (overlay) { + overlay.addEventListener("click", function() { toggleNotesPanel(); }); + } +})(); diff --git a/src/worker.js b/src/worker.js index 0af9783..3d7d6b7 100644 --- a/src/worker.js +++ b/src/worker.js @@ -4,7 +4,7 @@ * API routes (all others fall through to static assets): * POST /api/track – fire-and-forget click tracking * GET /api/tool-stats – per-tool click + vote counts - * POST /api/vote – community upvote / downvote + * POST /api/vote – community 5-star rating * POST /api/report – flag dead link / paywalled / incorrect info * * KV binding: CLICK_DATA (configured in wrangler.jsonc + Pages dashboard) @@ -15,7 +15,7 @@ * - No cookies used or set * - session_hash is client-generated and ephemeral (sessionStorage) * - Rate-limit key TTL: 2 min (60 req/min ceiling per session_hash) - * - Vote dedup: permanent per session (sessionStorage already limits scope) + * - Rating dedup: permanent per session (sessionStorage already limits scope) * - Report dedup key TTL: 7 days (prevents same session from inflating counts) */ @@ -134,10 +134,10 @@ async function handleTrack(request, env) { /** * POST /api/vote - * Body: { tool_id: string, direction: "up" | "down" | null, session_hash: string } - * direction=null removes the current vote (toggle off) + * Body: { tool_id: string, rating: 0|0.5|1|...|5|null, session_hash: string } + * rating=null removes the current rating (toggle off) * - * Returns: { ok: true, score: number, userVote: "up" | "down" | null } + * Returns: { ok: true, average: number, count: number, userRating: number|null } */ async function handleVote(request, env) { const origin = request.headers.get("Origin") || ""; @@ -149,68 +149,75 @@ async function handleVote(request, env) { return jsonResponse({ ok: false, error: "invalid json" }, 400, origin); } - const { tool_id, direction, session_hash } = body; + const { tool_id, rating, session_hash } = body; const validationError = validateCommon(tool_id, session_hash); if (validationError) { return jsonResponse({ ok: false, error: validationError }, 400, origin); } - if (direction !== "up" && direction !== "down" && direction !== null) { - return jsonResponse( - { ok: false, error: "direction must be 'up', 'down', or null" }, - 400, - origin - ); + if (rating !== null) { + var validRatings = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5]; + if (typeof rating !== "number" || validRatings.indexOf(rating) === -1) { + return jsonResponse( + { ok: false, error: "rating must be 0-5 in 0.5 increments, or null" }, + 400, + origin + ); + } } if (await isRateLimited(env, session_hash)) { return jsonResponse({ ok: false, error: "rate limited" }, 429, origin); } - const userVoteKey = `uservote:${session_hash}:${tool_id}`; - const upKey = `votes:up:${tool_id}`; - const downKey = `votes:down:${tool_id}`; + const userRatingKey = `rating:${session_hash}:${tool_id}`; + const sumKey = `ratings:sum:${tool_id}`; + const countKey = `ratings:count:${tool_id}`; // Read current state in parallel - const [prevVoteRaw, upRaw, downRaw] = await Promise.all([ - env.CLICK_DATA.get(userVoteKey), - env.CLICK_DATA.get(upKey), - env.CLICK_DATA.get(downKey), + const [prevRatingRaw, sumRaw, countRaw] = await Promise.all([ + env.CLICK_DATA.get(userRatingKey), + env.CLICK_DATA.get(sumKey), + env.CLICK_DATA.get(countKey), ]); - const prevVote = prevVoteRaw; // "up", "down", or null - let upCount = upRaw ? parseInt(upRaw, 10) : 0; - let downCount = downRaw ? parseInt(downRaw, 10) : 0; + const prevRating = prevRatingRaw ? parseFloat(prevRatingRaw) : null; + let sum = sumRaw ? parseFloat(sumRaw) : 0; + let count = countRaw ? parseInt(countRaw, 10) : 0; - // Determine the new vote: - // If same direction is sent again, treat as toggle-off (remove vote) - let newVote = direction; - if (direction !== null && direction === prevVote) { - newVote = null; // toggle off + // Toggle off if same rating submitted again + let newRating = rating; + if (rating !== null && rating === prevRating) { + newRating = null; } - // Undo previous vote - if (prevVote === "up") upCount = Math.max(0, upCount - 1); - if (prevVote === "down") downCount = Math.max(0, downCount - 1); + // Undo previous rating + if (prevRating !== null) { + sum = Math.max(0, sum - prevRating); + count = Math.max(0, count - 1); + } - // Apply new vote - if (newVote === "up") upCount++; - if (newVote === "down") downCount++; + // Apply new rating + if (newRating !== null) { + sum += newRating; + count++; + } // Persist const writes = [ - env.CLICK_DATA.put(upKey, String(upCount)), - env.CLICK_DATA.put(downKey, String(downCount)), + env.CLICK_DATA.put(sumKey, String(sum)), + env.CLICK_DATA.put(countKey, String(count)), ]; - if (newVote === null) { - writes.push(env.CLICK_DATA.delete(userVoteKey)); + if (newRating === null) { + writes.push(env.CLICK_DATA.delete(userRatingKey)); } else { - writes.push(env.CLICK_DATA.put(userVoteKey, newVote)); + writes.push(env.CLICK_DATA.put(userRatingKey, String(newRating))); } await Promise.all(writes); + const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; return jsonResponse( - { ok: true, score: upCount - downCount, userVote: newVote }, + { ok: true, average, count, userRating: newRating }, 200, origin ); @@ -339,7 +346,7 @@ async function createGitHubIssue(env, tool_id, report_type, count) { /** * GET /api/tool-stats?tool_id= * - * Returns: { tool_id: string, clicks: number, votes: { up: number, down: number, score: number } } + * Returns: { tool_id: string, clicks: number, ratings: { average: number, count: number } } */ async function handleStats(request, env) { const origin = request.headers.get("Origin") || ""; @@ -350,18 +357,19 @@ async function handleStats(request, env) { return jsonResponse({ ok: false, error: "invalid tool_id" }, 400, origin); } - const [clicksRaw, upRaw, downRaw] = await Promise.all([ + const [clicksRaw, sumRaw, countRaw] = await Promise.all([ env.CLICK_DATA.get(`clicks:${tool_id}`), - env.CLICK_DATA.get(`votes:up:${tool_id}`), - env.CLICK_DATA.get(`votes:down:${tool_id}`), + env.CLICK_DATA.get(`ratings:sum:${tool_id}`), + env.CLICK_DATA.get(`ratings:count:${tool_id}`), ]); const clicks = clicksRaw ? parseInt(clicksRaw, 10) : 0; - const up = upRaw ? parseInt(upRaw, 10) : 0; - const down = downRaw ? parseInt(downRaw, 10) : 0; + const sum = sumRaw ? parseFloat(sumRaw) : 0; + const count = countRaw ? parseInt(countRaw, 10) : 0; + const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; return jsonResponse( - { tool_id, clicks, votes: { up, down, score: up - down } }, + { tool_id, clicks, ratings: { average, count } }, 200, origin );