├── .ignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── dependabot.yml ├── FUNDING.yml └── workflows │ └── build-and-release.yml ├── icon.png ├── icons ├── 2.png ├── 3.png ├── 4.png └── fallback_for_no_favicon.png ├── .gitignore ├── .rsync-exclude ├── scripts ├── search-for-link.sh ├── browsing-history-today.sh ├── open-urls-and-write-log.sh ├── OneUpdater.sh ├── search-selection.sh └── inline-results.js ├── LICENSE ├── Makefile ├── README.md ├── info.plist └── dependencies └── ddgr.py /.ignore: -------------------------------------------------------------------------------- 1 | dependencies/* -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/hyper-seek/HEAD/icon.png -------------------------------------------------------------------------------- /icons/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/hyper-seek/HEAD/icons/2.png -------------------------------------------------------------------------------- /icons/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/hyper-seek/HEAD/icons/3.png -------------------------------------------------------------------------------- /icons/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/hyper-seek/HEAD/icons/4.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac 2 | .DS_Store 3 | 4 | # Alfred 5 | prefs.plist 6 | *.alfredworkflow 7 | -------------------------------------------------------------------------------- /icons/fallback_for_no_favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgrieser/hyper-seek/HEAD/icons/fallback_for_no_favicon.png -------------------------------------------------------------------------------- /.rsync-exclude: -------------------------------------------------------------------------------- 1 | .rsync-exclude 2 | .gitignore 3 | .git/ 4 | .github/ 5 | docs/ 6 | assets/ 7 | prefs.plist 8 | README.md 9 | Makefile 10 | build-and-release.sh 11 | LICENSE 12 | dependencies/ddgr.py 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore(dependabot): " 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/displaying-a-sponsor-button-in-your-repository 2 | 3 | custom: https://www.paypal.me/ChrisGrieser 4 | ko_fi: pseudometa 5 | -------------------------------------------------------------------------------- /scripts/search-for-link.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | query="$*" 4 | 5 | # shellcheck disable=2154 6 | [[ "$include_unsafe" == "1" ]] && unsafe="--unsafe" 7 | 8 | # `--noua` disables user agent & fetches faster (~10% faster according to hyperfine) 9 | url=$(python3 ./dependencies/ddgr.py --num=1 --noua "$unsafe" --json "$query" | grep "url" | cut -d'"' -f4) 10 | mdlink="[$query]($url)" 11 | 12 | echo -n "$mdlink" 13 | -------------------------------------------------------------------------------- /scripts/browsing-history-today.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | today=$(date +%Y-%m-%d) 4 | 5 | # `!` negates a sed match, q quits. 6 | # -> Effectivlely, this reads the file until the first occurrence of a line that 7 | # is not $today 8 | 9 | # shellcheck disable=2154 # alfred var 10 | if [[ -f "$log_location" ]]; then 11 | sed "/$today/!q" "$log_location" | # read until it's not today 12 | sed '$d' | # remove last line 13 | cut -d" " -f2- # remove date 14 | else 15 | echo "No log file found at $log_location." 16 | fi 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: "Feature Request: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: feature-requested 8 | attributes: 9 | label: Feature Requested 10 | description: A clear and concise description of the feature. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain the request. 18 | - type: checkboxes 19 | id: checklist 20 | attributes: 21 | label: Checklist 22 | options: 23 | - label: The feature would be useful to more users than just me. 24 | required: true 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Christopher Grieser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: transfer-local-files, release 2 | #─────────────────────────────────────────────────────────────────────────────── 3 | 4 | transfer-local-files: 5 | workflow_id=$$(basename "$$PWD") && \ 6 | prefs_location=$$(grep "5" "$$HOME/Library/Application Support/Alfred/prefs.json" | cut -d'"' -f4 | sed -e 's|\\/|/|g' -e "s|^~|$$HOME|") && \ 7 | local_workflow="$$prefs_location/Alfred.alfredpreferences/workflows/$$workflow_id" && \ 8 | rsync --archive --delete --exclude-from="$$PWD/.rsync-exclude" "$$local_workflow/" "$$PWD" ; \ 9 | git status --short 10 | 11 | release: 12 | zsh ./build-and-release.sh 13 | 14 | ddgr: # update ddgr-dependency 15 | workflow_id=$$(basename "$$PWD") && \ 16 | prefs_location=$$(grep "5" "$$HOME/Library/Application Support/Alfred/prefs.json" | cut -d'"' -f4 | sed -e 's|\\/|/|g' -e "s|^~|$$HOME|") && \ 17 | local_workflow="$$prefs_location/Alfred.alfredpreferences/workflows/$$workflow_id" && \ 18 | curl -L "https://raw.githubusercontent.com/kometenstaub/ddgr/main/ddgr" -o ./dependencies/ddgr.py && \ 19 | chmod +x ./dependencies/ddgr.py && \ 20 | cp -fv ./dependencies/ddgr.py "$$local_workflow/dependencies/ddgr.py" 21 | -------------------------------------------------------------------------------- /scripts/open-urls-and-write-log.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | # shellcheck disable=2154 # Alfred variables 3 | 4 | # OPEN URL(S) 5 | last_given_url="$*" 6 | multi_select_buffer="$alfred_workflow_cache/multiSelectBuffer.txt" 7 | if [[ -f "$multi_select_buffer" ]]; then 8 | urls="$(cat "$multi_select_buffer")\n$last_given_url" 9 | rm "$multi_select_buffer" 10 | else 11 | urls="$last_given_url" 12 | fi 13 | echo "$urls" | xargs open 14 | 15 | #─────────────────────────────────────────────────────────────────────────────── 16 | # LOG URLs 17 | 18 | # log location left empty or non-existent = user decided not to save logs 19 | [[ ! -f "$log_location" ]] && return 0 20 | 21 | 22 | echo "$urls" | while read -r url; do 23 | # if query on search site, keep only the query part 24 | query_or_url=$(echo "$url" | sed -E 's/.*q=(.*)/\1/') 25 | 26 | # if query term and not an URL, decode the spaces 27 | [[ "$query_or_url" =~ "http" ]] || query_or_url=$(osascript -l JavaScript -e "decodeURIComponent('$query_or_url')") 28 | 29 | # prepend 30 | date_time_stamp=$(date +"%Y-%m-%d %H:%M") 31 | echo -e "$date_time_stamp – $query_or_url\n$(cat "$log_location")" >"$log_location" 32 | done 33 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release Alfred Workflow 2 | 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | WORKFLOW_NAME: ${{ github.event.repository.name }} 10 | VERSION: ${{ github.ref }} # = the tag used to trigger this action 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v6 19 | 20 | - name: Create Release 21 | id: create_release 22 | uses: actions/create-release@v1 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | VERSION: ${{ env.VERSION }} 26 | with: 27 | tag_name: ${{ env.VERSION }} 28 | release_name: ${{ env.VERSION }} 29 | draft: false 30 | prerelease: false 31 | 32 | - name: Build .alfredworkflow 33 | run: | 34 | zip --quiet --recurse-paths "${{ env.WORKFLOW_NAME }}.alfredworkflow" . \ 35 | --exclude ".git*" "Makefile" "build-and-release.sh" "README.md" "assets/*" ".DS_Store" ".rsync-exclude" 36 | 37 | - name: Upload .alfredworkflow as Release Asset 38 | id: upload-alfredworkflow 39 | uses: actions/upload-release-asset@v1 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | upload_url: ${{ steps.create_release.outputs.upload_url }} 44 | asset_path: ./${{ env.WORKFLOW_NAME }}.alfredworkflow 45 | asset_name: ${{ env.WORKFLOW_NAME }}.alfredworkflow 46 | asset_content_type: application/zip 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | attributes: 9 | label: Bug Description 10 | description: A clear and concise description of the bug. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain your problem. 18 | - type: textarea 19 | id: reproduction-steps 20 | attributes: 21 | label: To Reproduce 22 | description: Steps to reproduce the problem 23 | placeholder: | 24 | For example: 25 | 1. Go to '...' 26 | 2. Click on '...' 27 | 3. Scroll down to '...' 28 | - type: textarea 29 | id: debugging-log 30 | attributes: 31 | label: Debugging Log 32 | description: "You can get a debugging log by opening the workflow in Alfred preferences and pressing `⌘ + D`. A small window will open up which will log everything happening during the execution of the workflow. Select Change the 'All Information' dropdown to 'Core Information'. Use the malfunctioning part of the workflow once more, copy the content of the log window, and paste it here." 33 | render: Text 34 | validations: 35 | required: true 36 | - type: textarea 37 | id: workflow-configuration 38 | attributes: 39 | label: Workflow Configuration 40 | description: "Please add a screenshot of your [workflow configuration](https://www.alfredapp.com/help/workflows/user-configuration/)." 41 | validations: 42 | required: true 43 | - type: checkboxes 44 | id: checklist 45 | attributes: 46 | label: Checklist 47 | options: 48 | - label: I have [updated to the latest version](https://github.com/chrisgrieser/hyper-seek/releases/latest) of this workflow. 49 | required: true 50 | - label: I am using Alfred 5. (Older versions are not supported anymore.) 51 | required: true 52 | -------------------------------------------------------------------------------- /scripts/OneUpdater.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=2154,2155 3 | 4 | # THESE VARIABLES MUST BE SET. SEE THE ONEUPDATER README FOR AN EXPLANATION OF EACH. 5 | readonly remote_info_plist="https://raw.githubusercontent.com/chrisgrieser/hyper-seek/main/info.plist" 6 | readonly workflow_url="https://github.com/chrisgrieser/hyper-seek/releases/latest/download/hyper-seek.alfredworkflow" 7 | readonly download_type="direct" 8 | readonly frequency_check="1" 9 | 10 | #─────────────────────────────────────────────────────────────────────────────── 11 | 12 | # FROM HERE ON, CODE SHOULD BE LEFT UNTOUCHED! 13 | function abort { 14 | echo "${1}" >&2 15 | exit 1 16 | } 17 | 18 | function url_exists { 19 | curl --silent --location --output /dev/null --fail --range 0-0 "${1}" 20 | } 21 | 22 | function notification { 23 | local -r notificator="$(find . -type f -name 'notificator')" 24 | 25 | if [[ -f "${notificator}" && "$(/usr/bin/file --brief --mime-type "${notificator}")" == 'text/x-shellscript' ]]; then 26 | "${notificator}" --message "${1}" --title "${alfred_workflow_name}" --subtitle 'A new version is available' 27 | return 28 | fi 29 | 30 | osascript -e "display notification \"${1}\" with title \"${alfred_workflow_name}\" subtitle \"A new version is available\"" 31 | } 32 | 33 | # Local sanity checks 34 | readonly local_info_plist='info.plist' 35 | readonly local_version="$(/usr/libexec/PlistBuddy -c 'print version' "${local_info_plist}")" 36 | 37 | [[ -n "${local_version}" ]] || abort 'You need to set a workflow version in the configuration sheet.' 38 | [[ "${download_type}" =~ ^(direct|page|github_release)$ ]] || abort "'download_type' (${download_type}) needs to be one of 'direct', 'page', or 'github_release'." 39 | [[ "${frequency_check}" =~ ^[0-9]+$ ]] || abort "'frequency_check' (${frequency_check}) needs to be a number." 40 | 41 | # Check for updates 42 | if [[ $(find "${local_info_plist}" -mtime +"${frequency_check}"d) ]]; then 43 | # Remote sanity check 44 | if ! url_exists "${remote_info_plist}"; then 45 | abort "'remote_info_plist' (${remote_info_plist}) appears to not be reachable." 46 | fi 47 | 48 | readonly tmp_file="$(mktemp)" 49 | curl --silent --location --output "${tmp_file}" "${remote_info_plist}" 50 | readonly remote_version="$(/usr/libexec/PlistBuddy -c 'print version' "${tmp_file}")" 51 | rm "${tmp_file}" 52 | 53 | if [[ "${local_version}" == "${remote_version}" ]]; then 54 | touch "${local_info_plist}" # Reset timer by touching local file 55 | exit 0 56 | fi 57 | 58 | if [[ "${download_type}" == 'page' ]]; then 59 | notification 'Opening download page…' 60 | open "${workflow_url}" 61 | exit 0 62 | fi 63 | 64 | readonly download_url="$( 65 | if [[ "${download_type}" == 'github_release' ]]; then 66 | osascript -l JavaScript -e 'function run(argv) { return JSON.parse(argv[0])["assets"].find(asset => asset["browser_download_url"].endsWith(".alfredworkflow"))["browser_download_url"] }' "$(curl --silent "https://api.github.com/repos/${workflow_url}/releases/latest")" 67 | else 68 | echo "${workflow_url}" 69 | fi 70 | )" 71 | 72 | if url_exists "${download_url}"; then 73 | notification 'Downloading and installing…' 74 | readonly download_name="$(basename "${download_url}")" 75 | curl --silent --location --output "${HOME}/Downloads/${download_name}" "${download_url}" 76 | open "${HOME}/Downloads/${download_name}" 77 | else 78 | abort "'workflow_url' (${download_url}) appears to not be reachable." 79 | fi 80 | fi 81 | -------------------------------------------------------------------------------- /scripts/search-selection.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # IF SELECTION IS… 4 | # file path: reveal it in file explorer 5 | # directory path: open it 6 | # url: open in Browser 7 | # email: send to that address 8 | # some other text: google it & open first duckduckgo hit 9 | # empty: do nothing 10 | 11 | #─────────────────────────────────────────────────────────────────────────────── 12 | 13 | SEL="$*" 14 | # when called via external trigger, will have no input, therefore copying 15 | # selection then 16 | if [[ -z "$SEL" ]]; then 17 | PREV_CLIPBOARD=$(pbpaste) 18 | osascript -e 'tell application "System Events" to keystroke "c" using {command down}' 19 | sleep 0.1 20 | SEL=$(pbpaste) 21 | # restore clipboard 22 | [[ -n "$PREV_CLIPBOARD" ]] && echo "$PREV_CLIPBOARD" | pbcopy 23 | [[ -z "$SEL" ]] && return 1 # = no selection 24 | fi 25 | 26 | # clean up 27 | SEL=$(echo -n "$SEL" | sed -e 's/^ *//' -e 's/ *$//') # trims whitespace 28 | SEL="${SEL/#\~/$HOME}" # resolve ~ 29 | 30 | # openers 31 | if [[ -f "$SEL" ]]; then # file 32 | open -R "$SEL" 33 | elif [[ -d "$SEL" || "$SEL" =~ ^http.* ]]; then # url or directory 34 | open "$SEL" 35 | elif [[ "$SEL" =~ "@" ]]; then # mail 36 | open "mailto:$SEL" 37 | elif [[ -n "$SEL" ]]; then 38 | SEL=${SEL/\'/\\\'} 39 | URL_ENCODED_SEL=$(osascript -l JavaScript -e "encodeURIComponent('$SEL')") 40 | # shellcheck disable=2154 41 | URL_2="$search_site$URL_ENCODED_SEL" 42 | # shellcheck disable=2154 43 | URL_1="https://duckduckgo.com/?q=$URL_ENCODED_SEL+!ducky&kl=$region" 44 | 45 | #──────────────────────────────────────────────────────────────────────────── 46 | # LOGGING 47 | 48 | # shellcheck disable=2154 # Alfred variable 49 | if [[ -f "$log_location" ]]; then # only log if it is set 50 | date_time_stamp=$(date +"%Y-%m-%d %H:%M") 51 | echo -e "$date_time_stamp – $SEL\n$(cat "$log_location")" >"$log_location" 52 | fi 53 | 54 | #──────────────────────────────────────────────────────────────────────────── 55 | # OPEN FIRST URL 56 | open "$URL_1" 57 | 58 | # OPEN SECOND URL IN BACKGROUND 59 | # Use AppleScript instead of JXA because the latter cannot create tabs at specific indexes 60 | # Call it via the shell because otherwise the code is complicated by "using terms from" 61 | # which requires a specific browser to be installed 62 | front_browser="$(osascript -e 'tell application "System Events" to return name of first process whose frontmost is true')" 63 | 64 | if [[ "${front_browser}" == 'Safari'* || "${front_browser}" == 'Webkit'* ]]; then 65 | osascript -e " 66 | tell application \"${front_browser}\" to tell front window 67 | set tabIndex to index of current tab 68 | make new tab at after tab tabIndex with properties {URL:\"${URL_2}\"} 69 | end tell" >/dev/null # Ignore stdout, otherwise tab info is printed 70 | elif [[ "${front_browser}" == 'Google Chrome'* || "${front_browser}" == 'Chromium'* || "${front_browser}" == 'Opera'* || "${front_browser}" == 'Vivaldi'* || "${front_browser}" == 'Brave Browser'* || "${front_browser}" == 'Microsoft Edge'* ]]; then 71 | osascript -e " 72 | tell application \"${front_browser}\" to tell front window 73 | set tabIndex to active tab index 74 | make new tab at after tab tabIndex with properties {URL:\"${URL_2}\"} 75 | set active tab index to tabIndex 76 | end tell" 77 | # As of Orion 0.99.124.1 and Arc 0.105.3, neither exposes tab indexes via AppleScript 78 | elif [[ "${front_browser}" == 'Orion' || "${front_browser}" == 'Arc' ]]; then 79 | osascript -e " 80 | tell application \"${front_browser}\" to tell front window 81 | make new tab with properties {URL:\"${URL_2}\"} 82 | end tell" >/dev/null # Ignore stdout, otherwise tab info is printed 83 | # Browser without AppleScript support, such as Firefox 84 | else 85 | open "${URL_2}" 86 | fi 87 | fi 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hyper Seek 2 | 3 | ![](https://img.shields.io/github/downloads/chrisgrieser/hyper-seek/total?label=Total%20Downloads&style=plastic) ![](https://img.shields.io/github/v/release/chrisgrieser/hyper-seek?label=Latest%20Release&style=plastic) 4 | 5 | [Alfred workflow](https://www.alfredapp.com/) that shows inline search results, without a keyword. 6 | 7 | Showcase image 8 | 9 | ## Table of Contents 10 | 11 | - [Features](#features) 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [Inline results](#inline-results) 15 | - [Global search hotkey](#global-search-hotkey) 16 | - [Add link to selection](#add-link-to-selection) 17 | - [Credits](#credits) 18 | 19 | 20 | ## Features 21 | - Inline Search Results, similar to Spotlight on iOS. 22 | - Results *without* a keyword. Results are always shown alongside your other keywords. 23 | - Multi-Selection of URLs to open. 24 | - Global Search Hotkey: Search for terms, open URLs, write mails, etc. 25 | - Add link to selected text: Turns selected text into a Markdown link. 26 | 27 | ## Installation 28 | 1. [➡️ Download the latest release of the workflow.](https://github.com/chrisgrieser/hyper-seek/releases/latest) 29 | 2. Add this workflow as [fallback search](https://www.alfredapp.com/help/features/default-results/fallback-searches/). 30 | 31 | *An earlier version of this workflow required `ddgr`. However, `ddgr` is not required anymore.* 32 | 33 | __Auto-Update__ 34 | Due to the nature of this workflow, it is not going to be submitted to the Alfred Gallery. Therefore, the workflow includes its own auto-update function. 35 | 36 | ## Usage 37 | 38 | ### Inline results 39 | Typing anything in Alfred shows inline search results. You do not need to use a keyword. 40 | - : Search for the query term, or open result. 41 | - : Copy URL to clipboard. 42 | - (hold): Show full URL. 43 | - (hold): Show preview text of search result. 44 | 45 | > [!NOTE] 46 | > In the [Alfred Advanced Settings](https://www.alfredapp.com/help/advanced/), set to `Action all visible results`. This allows you to open all search results at once. 47 | 48 | ### Global search hotkey 49 | Configure the [hotkey](https://www.alfredapp.com/help/workflows/triggers/hotkey/) to be able to search for any selection. The resulting action depends on the type of text selected: 50 | - file path → reveal file in Finder 51 | - directory path → open directory in Finder 52 | - URL → open in default browser 53 | - eMail → send eMail to that address in your default mail app 54 | - Some other text → search for selection & open first search result (`I'm feeling lucky`) 55 | 56 | ### Add link to selection 57 | Configure another [hotkey](https://www.alfredapp.com/help/workflows/triggers/hotkey/) to turn selected text into a Markdown link, with the URL of the first search result for that text as URL of the Markdown link. This feature is essentially a simplified version of [Brett Terpstra's SearchLink](https://brettterpstra.com/projects/searchlink/). 58 | 59 | ## License 60 | - `ddgr` is included in this project and licensed under [GNU GPL v3](https://github.com/kometenstaub/ddgr/blob/main/LICENSE). 61 | - `Hyper Seek` is licensed under the [MIT License](https://github.com/chrisgrieser/hyper-seek/blob/main/LICENSE). 62 | 63 | ## Credits 64 | 65 | **About Me** 66 | In my day job, I am a sociologist studying the social mechanisms underlying the digital economy. For my PhD project, I investigate the governance of the app economy and how software ecosystems manage the tension between innovation and compatibility. If you are interested in this subject, feel free to get in touch. 67 | 68 | **Profiles** 69 | - [reddit](https://www.reddit.com/user/pseudometapseudo) 70 | - [Discord](https://discordapp.com/users/462774483044794368/) 71 | - [Academic Website](https://chris-grieser.de/) 72 | - [Mastodon](https://pkm.social/@pseudometa) 73 | - [Twitter](https://twitter.com/pseudo_meta) 74 | - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser) 75 | - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/) 76 | 77 | **Buy Me a Coffee** 78 |
79 | Buy Me a Coffee at ko-fi.com 80 | -------------------------------------------------------------------------------- /scripts/inline-results.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | ObjC.import("stdlib"); 3 | ObjC.import("Foundation"); 4 | const app = Application.currentApplication(); 5 | app.includeStandardAdditions = true; 6 | 7 | //────────────────────────────────────────────────────────────────────────────── 8 | 9 | /** @typedef {Object} ddgrResponse (of the fork) 10 | * @property {string} instant_answer 11 | * @property {ddgrResult[]} results 12 | * @property {string?} query (manually added by this workflow for the cache) 13 | */ 14 | 15 | /** @typedef {Object} ddgrResult 16 | * @property {string} title 17 | * @property {string} abstract 18 | * @property {string} url 19 | */ 20 | 21 | //────────────────────────────────────────────────────────────────────────────── 22 | 23 | /** @param {string} path */ 24 | function readFile(path) { 25 | const data = $.NSFileManager.defaultManager.contentsAtPath(path); 26 | const str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); 27 | return ObjC.unwrap(str); 28 | } 29 | 30 | /** @param {string} filepath @param {string} text */ 31 | function writeToFile(filepath, text) { 32 | const str = $.NSString.alloc.initWithUTF8String(text); 33 | str.writeToFileAtomicallyEncodingError(filepath, true, $.NSUTF8StringEncoding, null); 34 | } 35 | 36 | /** searches for any `.plist` more recently modified than the cache to determine 37 | * if the cache is outdated. Cannot use the workflow folder's mdate, since it 38 | * is too far up, and macOS does only changes the mdate of enclosing folders, 39 | * but not of their parents. 40 | * - Considers the possibility of the cache not existing 41 | * - Considers the user potentially having set a custom preferences location, by 42 | * simply searching for the `.plist` files relative to this workflow's folder. 43 | * @param {string} cachePath 44 | */ 45 | function keywordCacheIsOutdated(cachePath) { 46 | const cacheObj = Application("System Events").aliases[cachePath]; 47 | if (!cacheObj.exists()) return true; 48 | const cacheAgeMins = ((+new Date() - cacheObj.creationDate()) / 1000 / 60).toFixed(0); 49 | const workflowConfigChanges = app.doShellScript( 50 | `find .. -depth 2 -name "*.plist" -mtime -${cacheAgeMins}m`, 51 | ); 52 | const webSearchConfigChanges = app.doShellScript( 53 | `find ../../preferences/features/websearch -name "prefs.plist" -mtime -${cacheAgeMins}m`, 54 | ); 55 | return webSearchConfigChanges || workflowConfigChanges; 56 | } 57 | 58 | // get the Alfred keywords and write them to the cachePath 59 | // PERF Saving keywords in a cache saves ~250ms for me (50+ workflows, 180+ keywords) 60 | /** @param {string} cachePath */ 61 | function refreshKeywordCache(cachePath) { 62 | console.log("Refreshing keyword cache…"); 63 | const timelogStart = +new Date(); 64 | 65 | const workflowKeywords = app 66 | // $alfred_workflow_uid is identical with this workflow's foldername, which 67 | // is excluded from the results, since this workflows keywords do not need 68 | // to be removed 69 | .doShellScript( 70 | 'grep -A1 "keyword" ../**/info.plist | grep "" | grep -v "$alfred_workflow_uid" || true', 71 | ) 72 | .split("\r") 73 | .reduce((keywords, line) => { 74 | const value = line.split(">")[1].split("<")[0]; 75 | 76 | // DOCS ALFRED KEYWORDS https://www.alfredapp.com/help/workflows/advanced/keywords/ 77 | // CASE 1: `{var:alfred_var}` -> configurable keywords 78 | if (value.startsWith("{var:")) { 79 | const varName = value.split("{var:")[1].split("}")[0]; 80 | const workflowPath = line.split("/info.plist")[0]; 81 | // CASE 1a: user-set keywords 82 | // (wrapped in try, since `plutil` will fail, as the value isn't saved in prefs.plist) 83 | try { 84 | // `..` is already the Alfred preferences directory, so no need to `cd` there 85 | const userKeyword = app.doShellScript( 86 | `plutil -extract "${varName}" raw -o - "${workflowPath}/prefs.plist"`, 87 | ); 88 | keywords.push(userKeyword.toLowerCase()); // in case user enters non-lowercase value 89 | } catch (_error) { 90 | // CASE 1b: keywords where user kept the default value 91 | try { 92 | const workflowConfig = JSON.parse( 93 | app.doShellScript( 94 | `plutil -extract "userconfigurationconfig" json -o - "${workflowPath}/info.plist"`, 95 | ), 96 | ); 97 | const defaultValue = workflowConfig.find( 98 | (/** @type {{ variable: string; }} */ option) => option.variable === varName, 99 | ).config.default; 100 | console.log("🪓 defaultValue:", defaultValue); 101 | keywords.push(defaultValue); 102 | } catch (_error) {} 103 | } 104 | } 105 | // CASE 2: `||` -> multiple keyword alternatives 106 | else if (value.includes("||")) { 107 | const multiKeyword = value.split("||"); 108 | keywords.push(...multiKeyword); 109 | } 110 | // CASE 3: regular keyword 111 | keywords.push(value); 112 | 113 | return keywords; 114 | }, []); 115 | 116 | // CASE 5: Pre-installed Searches 117 | const preinstalledSearches = app.doShellScript( 118 | "grep --files-without-match 'disabled' ../../preferences/features/websearch/**/prefs.plist | " + 119 | "xargs -I {} grep -A1 'keyword' '{}' | grep '' || true", 120 | ); 121 | // check for the possibility of user having all searches disabled 122 | const preinstallKeywords = []; 123 | if (preinstalledSearches) { 124 | for (const line of preinstalledSearches.split("\r")) { 125 | const searchKeyword = line.split(">")[1].split("<")[0]; 126 | preinstallKeywords.push(searchKeyword); 127 | } 128 | } 129 | 130 | // CASE 6: User Searches 131 | const userSearches = JSON.parse( 132 | app.doShellScript("plutil -convert json ../../preferences/features/websearch/prefs.plist -o - || true") || 133 | "{}", 134 | ).customSites; 135 | const userSearchKeywords = []; 136 | if (userSearches) { 137 | for (const uuid in Object.keys(userSearches)) { 138 | const searchObj = userSearches[uuid]; 139 | if (searchObj?.enabled) userSearchKeywords.push(searchObj.keyword); 140 | } 141 | } 142 | 143 | // CASE 7: Keywords from this workflow 144 | // (not covered by earlier cases, since the workflow folder is excluded to 145 | // prevent the addition of the pseudo-keywords "a, b, c, …" in the list of 146 | // ignored keywords.) 147 | const thisWorkflowKeywords = app 148 | .doShellScript('grep -A1 "keyword" ./info.plist | grep "" || true') 149 | .split("\r") 150 | .reduce((acc, line) => { 151 | const value = line.split(">")[1].split("<")[0]; 152 | if (value.startsWith(":") || value.startsWith("a||b")) return acc; 153 | acc.push(value); 154 | return acc; 155 | }, []); 156 | 157 | // FILTER IRRELEVANT KEYWORDS 158 | // - only the first word of a keyword matters 159 | // - only keywords with letter as first char matter 160 | // - unique keywords 161 | const allKeywords = [ 162 | ...workflowKeywords, 163 | ...thisWorkflowKeywords, 164 | ...preinstallKeywords, 165 | ...userSearchKeywords, 166 | ]; 167 | const relevantKeywords = allKeywords.reduce((acc, keyword) => { 168 | const firstWord = keyword.split(" ")[0]; 169 | if (firstWord.match(/^[a-z]/)) acc.push(firstWord); 170 | return acc; 171 | }, []); 172 | const uniqueKeywords = [...new Set(relevantKeywords)]; 173 | 174 | // WRITING & LOGGING 175 | writeToFile(cachePath, JSON.stringify(uniqueKeywords)); 176 | const durationTotalSecs = (+new Date() - timelogStart) / 1000; 177 | console.log(`Rebuilt keyword cache (${uniqueKeywords.length} keywords) in ${durationTotalSecs}s`); 178 | } 179 | 180 | const fileExists = (/** @type {string} */ filePath) => Application("Finder").exists(Path(filePath)); 181 | 182 | /** @param {string} topDomain where to get the favicon from */ 183 | function getFavicon(topDomain) { 184 | const durationLogStart = +new Date(); 185 | 186 | let targetFile = `${$.getenv("alfred_workflow_cache")}/${topDomain}.ico`; 187 | const useFaviconSetting = $.getenv("use_favicons") === "1"; 188 | 189 | if (!fileExists(targetFile)) { 190 | if (useFaviconSetting) { 191 | // Normally, `curl` does exit 0 even when the website reports 404. 192 | // With `curl --fail`, it will exit non-zero instead. However, 193 | // errors make `doShellScript` fail, so we need to use `try/catch` 194 | try { 195 | // PERF use favicon instead of touchicon (also more often available) 196 | // const imageUrl = `https://${topDomain}/apple-touch-icon.png`; 197 | const imageUrl = `https://${topDomain}/favicon.ico`; 198 | app.doShellScript(`curl --location --fail "${imageUrl}" --output "${targetFile}"`); 199 | } catch (_error) { 200 | targetFile = ""; // = not found -> use default icon 201 | } 202 | } else { 203 | targetFile = ""; 204 | } 205 | } 206 | 207 | const durationMs = +new Date() - durationLogStart; 208 | return { iconPath: targetFile, faviconMs: durationMs }; 209 | } 210 | 211 | function ensureCacheFolder() { 212 | const finder = Application("Finder"); 213 | const cacheDir = $.getenv("alfred_workflow_cache"); 214 | if (!finder.exists(Path(cacheDir))) { 215 | console.log("Cache Dir does not exist and is created."); 216 | const cacheDirBasename = $.getenv("alfred_workflow_bundleid"); 217 | const cacheDirParent = cacheDir.slice(0, -cacheDirBasename.length); 218 | finder.make({ 219 | new: "folder", 220 | at: Path(cacheDirParent), 221 | withProperties: { name: cacheDirBasename }, 222 | }); 223 | } 224 | } 225 | 226 | /** 227 | * @param {string} bufferPath 228 | * @param {string} instantAnswer 229 | */ 230 | function writeInstantAnswer(bufferPath, instantAnswer) { 231 | let infoText, source; 232 | try { 233 | [, infoText, source] = instantAnswer.match(/(.*)\s+More at (.*?)$/); 234 | } catch (_error) { 235 | infoText = instantAnswer; 236 | } 237 | const answerAsHtml = ` 238 | 247 |
248 |

${infoText}

249 | – ${source} 250 |
251 | `; 252 | writeToFile(bufferPath, answerAsHtml); 253 | } 254 | 255 | //────────────────────────────────────────────────────────────────────────────── 256 | //────────────────────────────────────────────────────────────────────────────── 257 | 258 | /** @type {AlfredRun} */ 259 | // biome-ignore lint/correctness/noUnusedVariables: Alfred run 260 | // biome-ignore lint/nursery/noExcessiveComplexity: 261 | function run(argv) { 262 | const timelogStart = +new Date(); 263 | let favIconTotalMs = 0; 264 | 265 | //─────────────────────────────────────────────────────────────────────────── 266 | // CONFIG 267 | let resultsToFetch = parseInt($.getenv("inline_results_to_fetch")) || 5; 268 | if (resultsToFetch < 1) resultsToFetch = 1; 269 | else if (resultsToFetch > 25) resultsToFetch = 25; // maximum supported by `ddgr` 270 | 271 | let minQueryLength = parseInt($.getenv("minimum_query_length")) || 3; 272 | if (minQueryLength < 0) minQueryLength = 0; 273 | else if (minQueryLength > 10) minQueryLength = 10; // prevent accidental high values 274 | 275 | const includeUnsafe = $.getenv("include_unsafe") === "1" ? "--unsafe" : ""; 276 | const ignoreAlfredKeywordsEnabled = $.getenv("ignore_alfred_keywords") === "1"; 277 | 278 | // https://duckduckgo.com/duckduckgo-help-pages/settings/params/ 279 | const searchRegion = $.getenv("region") === "none" ? "" : "--reg=" + $.getenv("region"); 280 | 281 | //─────────────────────────────────────────────────────────────────────────── 282 | 283 | /** @type{"fallback"|"default"} */ 284 | const mode = $.NSProcessInfo.processInfo.environment.objectForKey("mode").js || "default"; 285 | 286 | // HACK script filter is triggered with any letter of the roman alphabet, and 287 | // then prepended here, to trigger this workflow with any search term 288 | const scriptFilterKeyword = 289 | $.NSProcessInfo.processInfo.environment.objectForKey("alfred_workflow_keyword").js || ""; 290 | const query = (scriptFilterKeyword + argv[0]).trim(); 291 | ensureCacheFolder(); 292 | 293 | // GUARD CLAUSE 1: query is URL or too short 294 | if (query.match(/^\w+:/) && mode !== "fallback") { 295 | console.log("Ignored (URL)"); 296 | return; 297 | } else if (query.length < minQueryLength && mode !== "fallback") { 298 | console.log("Ignored (Min Query Length)"); 299 | return; 300 | } 301 | 302 | // GUARD CLAUSE 2: extra ignore keywords 303 | const ignoreExtraWordsStr = $.getenv("ignore_extra_words"); 304 | if (ignoreExtraWordsStr !== "" && mode !== "fallback") { 305 | const ignoreExtraWords = ignoreExtraWordsStr.split(/ ?, ?/); 306 | const queryFirstWord = query.split(" ")[0]; 307 | if (ignoreExtraWords.includes(queryFirstWord)) { 308 | console.log("Ignored (extra ignore word)"); 309 | return; 310 | } 311 | } 312 | 313 | // GUARD CLAUSE 3: first word of query is Alfred keyword 314 | // (guard clause is ignored when doing fallback search 315 | // since in that case we know we do not need to ignore anything.) 316 | if (ignoreAlfredKeywordsEnabled && mode !== "fallback") { 317 | const keywordCachePath = $.getenv("alfred_workflow_cache") + "/alfred_keywords.json"; 318 | if (keywordCacheIsOutdated(keywordCachePath)) refreshKeywordCache(keywordCachePath); 319 | const alfredKeywords = JSON.parse(readFile(keywordCachePath)); 320 | const queryFirstWord = query.split(" ")[0]; 321 | if (alfredKeywords.includes(queryFirstWord)) { 322 | console.log(`Ignored (Alfred keyword: ${queryFirstWord})`); 323 | return; 324 | } 325 | } 326 | 327 | // GUARD CLAUSE 4: use old results 328 | // -> get values from previous run 329 | const oldQuery = $.NSProcessInfo.processInfo.environment.objectForKey("oldQuery").js; 330 | const oldResults = $.NSProcessInfo.processInfo.environment.objectForKey("oldResults").js || "[]"; 331 | 332 | const querySearchUrl = $.getenv("search_site") + encodeURIComponent(query).replaceAll("'", "%27"); 333 | /** @type AlfredItem */ 334 | const searchForQuery = { 335 | title: `"${query}"`, 336 | uid: query, 337 | arg: querySearchUrl, 338 | }; 339 | 340 | // PERF & HACK If the user is typing, return early to guarantee the top entry 341 | // is the currently typed query. If we waited for `ddgr`, a fast typer would 342 | // search for an incomplete query. 343 | const userIsTyping = query !== oldQuery; 344 | if (userIsTyping) { 345 | searchForQuery.subtitle = "Loading Inline Results…"; 346 | return JSON.stringify({ 347 | rerun: 0.1, 348 | skipknowledge: true, 349 | variables: { oldResults: oldResults, oldQuery: query }, 350 | items: [searchForQuery].concat(JSON.parse(oldResults)), 351 | }); 352 | } 353 | 354 | //─────────────────────────────────────────────────────────────────────────── 355 | // MAIN: request NEW results 356 | 357 | // PERF cache `ddgr` response so that re-opening Alfred does not re-fetch results 358 | const responseCachePath = $.getenv("alfred_workflow_cache") + "/reponseCache.json"; 359 | const responseCache = JSON.parse(readFile(responseCachePath) || "{}"); 360 | /** @type{ddgrResponse} */ 361 | let response; 362 | 363 | if (responseCache.query === query) { 364 | response = responseCache; 365 | } else { 366 | // NOTE using a fork of ddgr which includes the instant_answer when using `--json` 367 | // https://github.com/kometenstaub 368 | // PERF the number of results fetched has basically no effect on the speed 369 | // (less than 40ms difference between 1 and 25 results), so there is no use 370 | // in restricting the number of results for performance. (Except for 25 being 371 | // ddgr's maximum) 372 | const escapedQuery = query.replaceAll('"', '\\"'); 373 | const ddgr = "python3 ./dependencies/ddgr.py"; 374 | const ddgrCmd = `${ddgr} --noua --json ${includeUnsafe} --num=${resultsToFetch} ${searchRegion} "${escapedQuery}"`; 375 | response = JSON.parse(app.doShellScript(ddgrCmd)); 376 | response.query = query; 377 | writeToFile(responseCachePath, JSON.stringify(response)); 378 | } 379 | 380 | // RESULTS 381 | /** @type AlfredItem[] */ 382 | const newResults = response.results.map((item) => { 383 | const topDomain = item.url.split("/")[2]; 384 | 385 | let { iconPath, faviconMs } = getFavicon(topDomain); 386 | favIconTotalMs += faviconMs; 387 | if (!iconPath) iconPath = "icons/fallback_for_no_favicon.png"; 388 | 389 | return { 390 | title: item.title, 391 | subtitle: topDomain, 392 | uid: item.url, 393 | arg: item.url, 394 | icon: { path: iconPath }, 395 | mods: { 396 | shift: { subtitle: item.abstract }, 397 | alt: { subtitle: `⌥: Copy ➙ ${item.url}` }, // also makes holding alt show the full URL 398 | }, 399 | }; 400 | }); 401 | 402 | // INSTANT ANSWER: searchForQuery 403 | if (response.instant_answer) { 404 | searchForQuery.subtitle = "ℹ️ " + response.instant_answer; 405 | 406 | // buffer instant answer for quicklook 407 | const instantAnswerBuffer = $.getenv("alfred_workflow_cache") + "/instantAnswerBuffer.html"; 408 | writeInstantAnswer(instantAnswerBuffer, response.instant_answer); 409 | searchForQuery.quicklookurl = instantAnswerBuffer; 410 | } 411 | 412 | //─────────────────────────────────────────────────────────────────────────── 413 | 414 | // Pass to Alfred 415 | const alfredInput = JSON.stringify({ 416 | variables: { oldResults: JSON.stringify(newResults), oldQuery: query }, 417 | items: [searchForQuery].concat(newResults), 418 | }); 419 | 420 | // LOGGING 421 | const durationTotalSecs = (+new Date() - timelogStart) / 1000; 422 | let log; 423 | let time = `${durationTotalSecs}s`; 424 | const useFaviconSetting = $.getenv("use_favicons") === "1"; 425 | if (useFaviconSetting) time += `, favicons: ${favIconTotalMs / 1000}s`; 426 | if (mode === "default") { 427 | log = `Total: ${time}, "${query}"`; 428 | } else { 429 | log = `Total: ${time}, "${query}" (${mode})`; 430 | } 431 | 432 | console.log(log); 433 | return alfredInput; 434 | } 435 | -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | de.chris-grieser.hyper-seek 7 | connections 8 | 9 | 23B4E13F-A2E8-4FBE-83B8-4C4E4288C8B1 10 | 11 | 12 | destinationuid 13 | F6591673-575D-4105-B38F-59AD1457EF23 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | 25810527-3725-4BAA-91DA-7B4E6FCCCBB7 23 | 24 | 25 | destinationuid 26 | 47FB3A9E-27F5-43FE-9F8D-DA7D444CEAA1 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | vitoclose 32 | 33 | 34 | 35 | 37823B3A-8211-49F5-BC98-118E47141CB4 36 | 37 | 38 | destinationuid 39 | BE9EF990-1694-424F-93F7-2A310066A5FD 40 | modifiers 41 | 0 42 | modifiersubtext 43 | 44 | vitoclose 45 | 46 | 47 | 48 | 3F875B8F-22A9-4711-914D-C906CAC4F290 49 | 50 | 51 | destinationuid 52 | FB5D42B1-CF89-4DD7-B57A-1995FE9A1BF9 53 | modifiers 54 | 0 55 | modifiersubtext 56 | 57 | vitoclose 58 | 59 | 60 | 61 | 477EF12D-4892-4FE9-942B-A55E0C21A9BA 62 | 63 | 64 | destinationuid 65 | 37823B3A-8211-49F5-BC98-118E47141CB4 66 | modifiers 67 | 0 68 | modifiersubtext 69 | 70 | vitoclose 71 | 72 | 73 | 74 | 47FB3A9E-27F5-43FE-9F8D-DA7D444CEAA1 75 | 76 | 77 | destinationuid 78 | 3F875B8F-22A9-4711-914D-C906CAC4F290 79 | modifiers 80 | 0 81 | modifiersubtext 82 | 83 | vitoclose 84 | 85 | 86 | 87 | 6EDDEC35-4391-49A3-A93F-99B48265C880 88 | 89 | 90 | destinationuid 91 | D3C22147-3316-415A-B4C1-8AA201F0888E 92 | modifiers 93 | 0 94 | modifiersubtext 95 | 96 | vitoclose 97 | 98 | 99 | 100 | destinationuid 101 | 23B4E13F-A2E8-4FBE-83B8-4C4E4288C8B1 102 | modifiers 103 | 524288 104 | modifiersubtext 105 | ⌥: Copy URL 106 | vitoclose 107 | 108 | 109 | 110 | BAD2BCE8-96CD-4FF0-9445-860265918045 111 | 112 | 113 | destinationuid 114 | 393BA661-A566-4A4C-872F-3385CC07DDB9 115 | modifiers 116 | 0 117 | modifiersubtext 118 | 119 | vitoclose 120 | 121 | 122 | 123 | C950FDC3-9DD1-427D-82D5-2FC76BE7858E 124 | 125 | 126 | destinationuid 127 | FF680CD3-F6C1-4A64-B782-6C898AE55BB9 128 | modifiers 129 | 0 130 | modifiersubtext 131 | 132 | vitoclose 133 | 134 | 135 | 136 | D3C22147-3316-415A-B4C1-8AA201F0888E 137 | 138 | 139 | destinationuid 140 | 841BD8BB-2E10-4E6A-89DC-2C5DAA070D75 141 | modifiers 142 | 0 143 | modifiersubtext 144 | 145 | vitoclose 146 | 147 | 148 | 149 | FF680CD3-F6C1-4A64-B782-6C898AE55BB9 150 | 151 | 152 | destinationuid 153 | 6EDDEC35-4391-49A3-A93F-99B48265C880 154 | modifiers 155 | 0 156 | modifiersubtext 157 | 158 | vitoclose 159 | 160 | 161 | 162 | 163 | createdby 164 | Chris Grieser 165 | description 166 | Search for the Power User. Inline Results, without a keyword. 167 | disabled 168 | 169 | name 170 | Hyper Seek 171 | objects 172 | 173 | 174 | config 175 | 176 | action 177 | 0 178 | argument 179 | 1 180 | focusedappvariable 181 | 182 | focusedappvariablename 183 | 184 | hotkey 185 | 0 186 | hotmod 187 | 0 188 | leftcursor 189 | 190 | modsmode 191 | 0 192 | relatedAppsMode 193 | 0 194 | 195 | type 196 | alfred.workflow.trigger.hotkey 197 | uid 198 | BAD2BCE8-96CD-4FF0-9445-860265918045 199 | version 200 | 2 201 | 202 | 203 | config 204 | 205 | alignment 206 | 0 207 | backgroundcolor 208 | 209 | fadespeed 210 | 0 211 | fillmode 212 | 0 213 | font 214 | 215 | ignoredynamicplaceholders 216 | 217 | largetypetext 218 | {query} 219 | textcolor 220 | 221 | wrapat 222 | 90 223 | 224 | type 225 | alfred.workflow.output.largetype 226 | uid 227 | BE9EF990-1694-424F-93F7-2A310066A5FD 228 | version 229 | 3 230 | 231 | 232 | config 233 | 234 | concurrently 235 | 236 | escaping 237 | 0 238 | script 239 | 240 | scriptargtype 241 | 1 242 | scriptfile 243 | ./scripts/search-selection.sh 244 | type 245 | 8 246 | 247 | inboundconfig 248 | 249 | externalid 250 | search-selection 251 | 252 | type 253 | alfred.workflow.action.script 254 | uid 255 | 393BA661-A566-4A4C-872F-3385CC07DDB9 256 | version 257 | 2 258 | 259 | 260 | config 261 | 262 | concurrently 263 | 264 | escaping 265 | 0 266 | script 267 | 268 | scriptargtype 269 | 1 270 | scriptfile 271 | ./scripts/browsing-history-today.sh 272 | type 273 | 8 274 | 275 | type 276 | alfred.workflow.action.script 277 | uid 278 | 37823B3A-8211-49F5-BC98-118E47141CB4 279 | version 280 | 2 281 | 282 | 283 | config 284 | 285 | argumenttype 286 | 1 287 | keyword 288 | today 289 | subtext 290 | for today 291 | text 292 | 📅 {const:alfred_workflow_name} History 293 | withspace 294 | 295 | 296 | type 297 | alfred.workflow.input.keyword 298 | uid 299 | 477EF12D-4892-4FE9-942B-A55E0C21A9BA 300 | version 301 | 1 302 | 303 | 304 | config 305 | 306 | action 307 | 0 308 | argument 309 | 1 310 | focusedappvariable 311 | 312 | focusedappvariablename 313 | 314 | hotkey 315 | 0 316 | hotmod 317 | 0 318 | leftcursor 319 | 320 | modsmode 321 | 2 322 | relatedAppsMode 323 | 0 324 | 325 | type 326 | alfred.workflow.trigger.hotkey 327 | uid 328 | 25810527-3725-4BAA-91DA-7B4E6FCCCBB7 329 | version 330 | 2 331 | 332 | 333 | config 334 | 335 | autopaste 336 | 337 | clipboardtext 338 | {query} 339 | ignoredynamicplaceholders 340 | 341 | transient 342 | 343 | 344 | type 345 | alfred.workflow.output.clipboard 346 | uid 347 | FB5D42B1-CF89-4DD7-B57A-1995FE9A1BF9 348 | version 349 | 3 350 | 351 | 352 | config 353 | 354 | lastpathcomponent 355 | 356 | onlyshowifquerypopulated 357 | 358 | removeextension 359 | 360 | text 361 | "{query}" 362 | title 363 | ⏳ Searching… 364 | 365 | type 366 | alfred.workflow.output.notification 367 | uid 368 | 47FB3A9E-27F5-43FE-9F8D-DA7D444CEAA1 369 | version 370 | 1 371 | 372 | 373 | config 374 | 375 | concurrently 376 | 377 | escaping 378 | 0 379 | script 380 | 381 | scriptargtype 382 | 1 383 | scriptfile 384 | ./scripts/search-for-link.sh 385 | type 386 | 8 387 | 388 | type 389 | alfred.workflow.action.script 390 | uid 391 | 3F875B8F-22A9-4711-914D-C906CAC4F290 392 | version 393 | 2 394 | 395 | 396 | config 397 | 398 | concurrently 399 | 400 | escaping 401 | 0 402 | script 403 | # THESE VARIABLES MUST BE SET. SEE THE ONEUPDATER README FOR AN EXPLANATION OF EACH. 404 | readonly remote_info_plist='https://raw.githubusercontent.com/chrisgrieser/hyper-seek/main/info.plist' 405 | readonly workflow_url='https://github.com/chrisgrieser/hyper-seek/releases/latest/download/hyper-seek.alfredworkflow' 406 | readonly download_type='direct' 407 | readonly frequency_check='1' 408 | 409 | # FROM HERE ON, CODE SHOULD BE LEFT UNTOUCHED! 410 | function abort { 411 | echo "${1}" >&2 412 | exit 1 413 | } 414 | 415 | function url_exists { 416 | curl --silent --location --output /dev/null --fail --range 0-0 "${1}" 417 | } 418 | 419 | function notification { 420 | local -r notificator="$(find . -type f -name 'notificator')" 421 | 422 | if [[ -f "${notificator}" && "$(/usr/bin/file --brief --mime-type "${notificator}")" == 'text/x-shellscript' ]]; then 423 | "${notificator}" --message "${1}" --title "${alfred_workflow_name}" --subtitle 'A new version is available' 424 | return 425 | fi 426 | 427 | osascript -e "display notification \"${1}\" with title \"${alfred_workflow_name}\" subtitle \"A new version is available\"" 428 | } 429 | 430 | # Local sanity checks 431 | readonly local_info_plist='info.plist' 432 | readonly local_version="$(/usr/libexec/PlistBuddy -c 'print version' "${local_info_plist}")" 433 | 434 | [[ -n "${local_version}" ]] || abort 'You need to set a workflow version in the configuration sheet.' 435 | [[ "${download_type}" =~ ^(direct|page|github_release)$ ]] || abort "'download_type' (${download_type}) needs to be one of 'direct', 'page', or 'github_release'." 436 | [[ "${frequency_check}" =~ ^[0-9]+$ ]] || abort "'frequency_check' (${frequency_check}) needs to be a number." 437 | 438 | # Check for updates 439 | if [[ $(find "${local_info_plist}" -mtime +"${frequency_check}"d) ]]; then 440 | # Remote sanity check 441 | if ! url_exists "${remote_info_plist}"; then 442 | abort "'remote_info_plist' (${remote_info_plist}) appears to not be reachable." 443 | fi 444 | 445 | readonly tmp_file="$(mktemp)" 446 | curl --silent --location --output "${tmp_file}" "${remote_info_plist}" 447 | readonly remote_version="$(/usr/libexec/PlistBuddy -c 'print version' "${tmp_file}")" 448 | rm "${tmp_file}" 449 | 450 | if [[ "${local_version}" == "${remote_version}" ]]; then 451 | touch "${local_info_plist}" # Reset timer by touching local file 452 | exit 0 453 | fi 454 | 455 | if [[ "${download_type}" == 'page' ]]; then 456 | notification 'Opening download page…' 457 | open "${workflow_url}" 458 | exit 0 459 | fi 460 | 461 | readonly download_url="$( 462 | if [[ "${download_type}" == 'github_release' ]]; then 463 | osascript -l JavaScript -e 'function run(argv) { return JSON.parse(argv[0])["assets"].find(asset => asset["browser_download_url"].endsWith(".alfredworkflow"))["browser_download_url"] }' "$(curl --silent "https://api.github.com/repos/${workflow_url}/releases/latest")" 464 | else 465 | echo "${workflow_url}" 466 | fi 467 | )" 468 | 469 | if url_exists "${download_url}"; then 470 | notification 'Downloading and installing…' 471 | readonly download_name="$(basename "${download_url}")" 472 | curl --silent --location --output "${HOME}/Downloads/${download_name}" "${download_url}" 473 | open "${HOME}/Downloads/${download_name}" 474 | else 475 | abort "'workflow_url' (${download_url}) appears to not be reachable." 476 | fi 477 | fi 478 | scriptargtype 479 | 1 480 | scriptfile 481 | ./scripts/OneUpdater.sh 482 | type 483 | 8 484 | 485 | type 486 | alfred.workflow.action.script 487 | uid 488 | 841BD8BB-2E10-4E6A-89DC-2C5DAA070D75 489 | version 490 | 2 491 | 492 | 493 | config 494 | 495 | concurrently 496 | 497 | escaping 498 | 0 499 | script 500 | 501 | scriptargtype 502 | 1 503 | scriptfile 504 | ./scripts/open-urls-and-write-log.sh 505 | type 506 | 8 507 | 508 | type 509 | alfred.workflow.action.script 510 | uid 511 | D3C22147-3316-415A-B4C1-8AA201F0888E 512 | version 513 | 2 514 | 515 | 516 | config 517 | 518 | alfredfiltersresults 519 | 520 | alfredfiltersresultsmatchmode 521 | 0 522 | argumenttreatemptyqueryasnil 523 | 524 | argumenttrimmode 525 | 0 526 | argumenttype 527 | 1 528 | escaping 529 | 0 530 | keyword 531 | a||b||c||d||e||f||g||h||i||j||k||l||m||n||o||p||q||r||s||t||u||v||w||x||y||z 532 | queuedelaycustom 533 | 3 534 | queuedelayimmediatelyinitially 535 | 536 | queuedelaymode 537 | 0 538 | queuemode 539 | 2 540 | runningsubtext 541 | 542 | script 543 | 544 | scriptargtype 545 | 1 546 | scriptfile 547 | ./scripts/inline-results.js 548 | subtext 549 | 550 | title 551 | 552 | type 553 | 8 554 | withspace 555 | 556 | 557 | type 558 | alfred.workflow.input.scriptfilter 559 | uid 560 | 6EDDEC35-4391-49A3-A93F-99B48265C880 561 | version 562 | 3 563 | 564 | 565 | config 566 | 567 | autopaste 568 | 569 | clipboardtext 570 | {query} 571 | ignoredynamicplaceholders 572 | 573 | transient 574 | 575 | 576 | type 577 | alfred.workflow.output.clipboard 578 | uid 579 | 23B4E13F-A2E8-4FBE-83B8-4C4E4288C8B1 580 | version 581 | 3 582 | 583 | 584 | config 585 | 586 | lastpathcomponent 587 | 588 | onlyshowifquerypopulated 589 | 590 | removeextension 591 | 592 | text 593 | {query} 594 | title 595 | 📋 Copied 596 | 597 | type 598 | alfred.workflow.output.notification 599 | uid 600 | F6591673-575D-4105-B38F-59AD1457EF23 601 | version 602 | 1 603 | 604 | 605 | config 606 | 607 | subtext 608 | {query} 609 | text 610 | Trigger Search for Inline Results 611 | 612 | type 613 | alfred.workflow.trigger.fallback 614 | uid 615 | C950FDC3-9DD1-427D-82D5-2FC76BE7858E 616 | version 617 | 1 618 | 619 | 620 | config 621 | 622 | argument 623 | 624 | passthroughargument 625 | 626 | variables 627 | 628 | mode 629 | fallback 630 | 631 | 632 | type 633 | alfred.workflow.utility.argument 634 | uid 635 | FF680CD3-F6C1-4A64-B782-6C898AE55BB9 636 | version 637 | 1 638 | 639 | 640 | readme 641 | # Hyper Seek 642 | Search for the Power User. Inline Results, *without a keyword*. Multi-select results to open. 643 | 644 | ## Inline results (without a keyword!) 645 | - <kbd>⏎</kbd>: Search for the query term, or open result. 646 | - <kbd>⌥</kbd><kbd>⏎</kbd>: Copy URL to clipboard. 647 | - <kbd>⌥</kbd> (hold): Show full URL. 648 | - <kbd>⇧</kbd> (hold): Show preview text of search result. 649 | - <kbd>⇧</kbd> or <kbd>⌘Y</kbd> (on query with): Quick Look of the full instant answer. 650 | 651 | Use the keyword `today` to show a log of today's searches. 652 | 653 | **Recommendations** 654 | - Add this workflow as [fallback search](https://www.alfredapp.com/help/features/default-results/fallback-searches/). 655 | - In the [Alfred Advanced Settings](https://www.alfredapp.com/help/advanced/), set <kbd>⌃</kbd><kbd>⏎</kbd> to `Action all visible results`. This allows you to open all search results at once. 656 | 657 | ## Global search hotkey 658 | Selection is… 659 | - file path → reveal file in Finder 660 | - directory path → open directory in Finder 661 | - url → open in default browser 662 | - email → send eMail to that address in your default mail app 663 | - some other text → search for selection & open first search result ("I'm feeling lucky") 664 | 665 | ## Add link to selection 666 | Turns selected text into a markdown link, with the URL of the first search result for that text as URL for the markdown link. This feature is essentially a simplified version of [Brett Terpstra's SearchLink](https://brettterpstra.com/projects/searchlink/). 667 | 668 | ## Auto-Update 669 | Due to the nature of this workflow, it will not be submitted to the Alfred Gallery. Therefore, the workflow includes its own auto-update function, which checks every day for a new update. 670 | 671 | --- 672 | 673 | Created by [Chris Grieser](https://chris-grieser.de/). 674 | uidata 675 | 676 | 23B4E13F-A2E8-4FBE-83B8-4C4E4288C8B1 677 | 678 | colorindex 679 | 2 680 | xpos 681 | 260 682 | ypos 683 | 480 684 | 685 | 25810527-3725-4BAA-91DA-7B4E6FCCCBB7 686 | 687 | colorindex 688 | 8 689 | note 690 | Add link to selection 691 | xpos 692 | 30 693 | ypos 694 | 170 695 | 696 | 37823B3A-8211-49F5-BC98-118E47141CB4 697 | 698 | colorindex 699 | 3 700 | xpos 701 | 855 702 | ypos 703 | 15 704 | 705 | 393BA661-A566-4A4C-872F-3385CC07DDB9 706 | 707 | colorindex 708 | 5 709 | xpos 710 | 195 711 | ypos 712 | 15 713 | 714 | 3F875B8F-22A9-4711-914D-C906CAC4F290 715 | 716 | colorindex 717 | 8 718 | xpos 719 | 340 720 | ypos 721 | 170 722 | 723 | 477EF12D-4892-4FE9-942B-A55E0C21A9BA 724 | 725 | colorindex 726 | 3 727 | xpos 728 | 705 729 | ypos 730 | 15 731 | 732 | 47FB3A9E-27F5-43FE-9F8D-DA7D444CEAA1 733 | 734 | colorindex 735 | 8 736 | xpos 737 | 195 738 | ypos 739 | 170 740 | 741 | 6EDDEC35-4391-49A3-A93F-99B48265C880 742 | 743 | colorindex 744 | 10 745 | note 746 | triggers on every letter of the roman alphabet 747 | xpos 748 | 30 749 | ypos 750 | 430 751 | 752 | 841BD8BB-2E10-4E6A-89DC-2C5DAA070D75 753 | 754 | colorindex 755 | 7 756 | note 757 | OneUpdater 758 | xpos 759 | 405 760 | ypos 761 | 335 762 | 763 | BAD2BCE8-96CD-4FF0-9445-860265918045 764 | 765 | colorindex 766 | 5 767 | note 768 | Search & Lucky Search for Selected Text 769 | xpos 770 | 30 771 | ypos 772 | 15 773 | 774 | BE9EF990-1694-424F-93F7-2A310066A5FD 775 | 776 | colorindex 777 | 3 778 | xpos 779 | 1000 780 | ypos 781 | 15 782 | 783 | C950FDC3-9DD1-427D-82D5-2FC76BE7858E 784 | 785 | colorindex 786 | 12 787 | note 788 | fallback 789 | xpos 790 | 30 791 | ypos 792 | 745 793 | 794 | D3C22147-3316-415A-B4C1-8AA201F0888E 795 | 796 | colorindex 797 | 10 798 | note 799 | open URL(s) & log queries 800 | xpos 801 | 260 802 | ypos 803 | 335 804 | 805 | F6591673-575D-4105-B38F-59AD1457EF23 806 | 807 | colorindex 808 | 2 809 | xpos 810 | 405 811 | ypos 812 | 480 813 | 814 | FB5D42B1-CF89-4DD7-B57A-1995FE9A1BF9 815 | 816 | colorindex 817 | 8 818 | note 819 | paste 820 | xpos 821 | 485 822 | ypos 823 | 170 824 | 825 | FF680CD3-F6C1-4A64-B782-6C898AE55BB9 826 | 827 | colorindex 828 | 12 829 | note 830 | set mode 831 | xpos 832 | 190 833 | ypos 834 | 775 835 | 836 | 837 | userconfigurationconfig 838 | 839 | 840 | config 841 | 842 | default 843 | https://www.google.com/search?q= 844 | pairs 845 | 846 | 847 | Google 848 | https://www.google.com/search?q= 849 | 850 | 851 | Brave 852 | https://search.brave.com/search?q= 853 | 854 | 855 | DuckDuckGo 856 | https://duckduckgo.com/?q= 857 | 858 | 859 | Ecosia 860 | https://www.ecosia.org/search?q= 861 | 862 | 863 | 864 | description 865 | Where searching a query should be performed. (Inline results come from DuckDuckGo, regardless of this setting.) 866 | label 867 | Query Search Site 868 | type 869 | popupbutton 870 | variable 871 | search_site 872 | 873 | 874 | config 875 | 876 | default 877 | 878 | required 879 | 880 | text 881 | Ignore 882 | 883 | description 884 | When a keyword from another workflow or from a websearch is used, this workflow will not be triggered (recommended). 885 | label 886 | Alfred Keywords 887 | type 888 | checkbox 889 | variable 890 | ignore_alfred_keywords 891 | 892 | 893 | config 894 | 895 | default 896 | 897 | required 898 | 899 | trim 900 | 901 | verticalsize 902 | 3 903 | 904 | description 905 | Comma-separated list of more keywords to ignore. Use this if the "Ignore Alfred Keywords" setting is not fine-grained enough, or to ignore additional keywords. 906 | label 907 | Ignore extra words 908 | type 909 | textarea 910 | variable 911 | ignore_extra_words 912 | 913 | 914 | config 915 | 916 | default 917 | 3 918 | placeholder 919 | 3 920 | required 921 | 922 | trim 923 | 924 | 925 | description 926 | Minimum length of the query before inline results are shown. 927 | label 928 | Min Query Length 929 | type 930 | textfield 931 | variable 932 | minimum_query_length 933 | 934 | 935 | config 936 | 937 | default 938 | 5 939 | placeholder 940 | 5 941 | required 942 | 943 | trim 944 | 945 | 946 | description 947 | Number of inline results to display (1-25). 948 | label 949 | Num Inline Results 950 | type 951 | textfield 952 | variable 953 | inline_results_to_fetch 954 | 955 | 956 | config 957 | 958 | default 959 | 960 | required 961 | 962 | text 963 | Disable 964 | 965 | description 966 | for Inline Results 967 | label 968 | Safe Search 969 | type 970 | checkbox 971 | variable 972 | include_unsafe 973 | 974 | 975 | config 976 | 977 | default 978 | none 979 | pairs 980 | 981 | 982 | none 983 | none 984 | 985 | 986 | Germany 987 | de-de 988 | 989 | 990 | France 991 | fr-fr 992 | 993 | 994 | United States 995 | en-us 996 | 997 | 998 | Japan 999 | jp-jp 1000 | 1001 | 1002 | 1003 | description 1004 | region-specific search for the Inline Results 1005 | label 1006 | Region 1007 | type 1008 | popupbutton 1009 | variable 1010 | region 1011 | 1012 | 1013 | config 1014 | 1015 | default 1016 | 1017 | required 1018 | 1019 | text 1020 | enable 1021 | 1022 | description 1023 | Download favicons for websites. Does not work for every website and noticably increases loading time. Should not be combined with a high number of inline results. (However, performance will get better once a lot of favicons have been cached.) 1024 | label 1025 | Favicons 1026 | type 1027 | checkbox 1028 | variable 1029 | use_favicons 1030 | 1031 | 1032 | config 1033 | 1034 | default 1035 | 1036 | filtermode 1037 | 2 1038 | placeholder 1039 | 1040 | required 1041 | 1042 | 1043 | description 1044 | Leave empty to disable logging. 1045 | label 1046 | Log Location 1047 | type 1048 | filepicker 1049 | variable 1050 | log_location 1051 | 1052 | 1053 | variables 1054 | 1055 | multi_select_icon 1056 | 🔳 1057 | 1058 | version 1059 | 1.9.2 1060 | webaddress 1061 | https://chris-grieser.de/ 1062 | 1063 | 1064 | -------------------------------------------------------------------------------- /dependencies/ddgr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (C) 2016-2023 Arun Prakash Jana 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | 18 | import argparse 19 | import codecs 20 | import collections 21 | import functools 22 | import gzip 23 | import html.entities 24 | import html.parser 25 | import json 26 | import locale 27 | import logging 28 | import os 29 | import platform 30 | import shutil 31 | import signal 32 | from subprocess import Popen, PIPE, DEVNULL 33 | import sys 34 | import tempfile 35 | import textwrap 36 | import unicodedata 37 | import urllib.error 38 | import urllib.parse 39 | import urllib.request 40 | import webbrowser 41 | 42 | try: 43 | import readline 44 | except ImportError: 45 | pass 46 | 47 | try: 48 | import setproctitle 49 | setproctitle.setproctitle('ddgr') 50 | except (ImportError, Exception): 51 | pass 52 | 53 | # Basic setup 54 | 55 | logging.basicConfig(format='[%(levelname)s] %(message)s') 56 | LOGGER = logging.getLogger() 57 | LOGDBG = LOGGER.debug 58 | LOGERR = LOGGER.error 59 | 60 | 61 | def sigint_handler(signum, frame): 62 | print('\nInterrupted.', file=sys.stderr) 63 | sys.exit(1) 64 | 65 | 66 | try: 67 | signal.signal(signal.SIGINT, sigint_handler) 68 | except ValueError: 69 | # signal only works in main thread 70 | pass 71 | 72 | # Constants 73 | 74 | _VERSION_ = '2.1' 75 | 76 | COLORMAP = {k: '\x1b[%sm' % v for k, v in { 77 | 'a': '30', 'b': '31', 'c': '32', 'd': '33', 78 | 'e': '34', 'f': '35', 'g': '36', 'h': '37', 79 | 'i': '90', 'j': '91', 'k': '92', 'l': '93', 80 | 'm': '94', 'n': '95', 'o': '96', 'p': '97', 81 | 'A': '30;1', 'B': '31;1', 'C': '32;1', 'D': '33;1', 82 | 'E': '34;1', 'F': '35;1', 'G': '36;1', 'H': '37;1', 83 | 'I': '90;1', 'J': '91;1', 'K': '92;1', 'L': '93;1', 84 | 'M': '94;1', 'N': '95;1', 'O': '96;1', 'P': '97;1', 85 | 'x': '0', 'X': '1', 'y': '7', 'Y': '7;1', 86 | }.items()} 87 | 88 | USER_AGENT = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' 89 | 90 | TEXT_BROWSERS = ['elinks', 'links', 'lynx', 'w3m', 'www-browser'] 91 | 92 | INDENT = 5 93 | 94 | # Global helper functions 95 | 96 | 97 | def open_url(url): 98 | """Open an URL in the user's default web browser. 99 | 100 | The string attribute ``open_url.url_handler`` can be used to open URLs 101 | in a custom CLI script or utility. A subprocess is spawned with url as 102 | the parameter in this case instead of the usual webbrowser.open() call. 103 | 104 | Whether the browser's output (both stdout and stderr) are suppressed 105 | depends on the boolean attribute ``open_url.suppress_browser_output``. 106 | If the attribute is not set upon a call, set it to a default value, 107 | which means False if BROWSER is set to a known text-based browser -- 108 | elinks, links, lynx, w3m or 'www-browser'; or True otherwise. 109 | 110 | The string attribute ``open_url.override_text_browser`` can be used to 111 | ignore env var BROWSER as well as some known text-based browsers and 112 | attempt to open url in a GUI browser available. 113 | Note: If a GUI browser is indeed found, this option ignores the program 114 | option `show-browser-logs` 115 | """ 116 | LOGDBG('Opening %s', url) 117 | 118 | # Custom URL handler gets max priority 119 | if hasattr(open_url, 'url_handler'): 120 | with Popen([open_url.url_handler, url], stdin=PIPE) as pipe: 121 | pipe.communicate() 122 | return 123 | 124 | browser = webbrowser.get() 125 | if open_url.override_text_browser: 126 | browser_output = open_url.suppress_browser_output 127 | for name in [b for b in webbrowser._tryorder if b not in TEXT_BROWSERS]: 128 | browser = webbrowser.get(name) 129 | LOGDBG(browser) 130 | 131 | # Found a GUI browser, suppress browser output 132 | open_url.suppress_browser_output = True 133 | break 134 | 135 | if open_url.suppress_browser_output: 136 | _stderr = os.dup(2) 137 | os.close(2) 138 | _stdout = os.dup(1) 139 | if "microsoft" not in platform.uname()[3].lower(): 140 | os.close(1) 141 | fd = os.open(os.devnull, os.O_RDWR) 142 | os.dup2(fd, 2) 143 | os.dup2(fd, 1) 144 | try: 145 | browser.open(url, new=2) 146 | finally: 147 | if open_url.suppress_browser_output: 148 | os.close(fd) 149 | os.dup2(_stderr, 2) 150 | os.dup2(_stdout, 1) 151 | 152 | if open_url.override_text_browser: 153 | open_url.suppress_browser_output = browser_output 154 | 155 | 156 | def https_get(url, headers=None, proxies=None, expected_code=None): 157 | """Sends an HTTPS GET request; returns the HTTP status code and the 158 | decoded response payload. 159 | 160 | By default, HTTP 301, 302 and 303 are followed; all other non-2XX 161 | responses result in a urllib.error.HTTPError. If expected_code is 162 | supplied, a urllib.error.HTTPError is raised unless the status code 163 | matches expected_code. 164 | """ 165 | headers = headers or {} 166 | proxies = {'https': proxies['https']} if proxies.get('https') else {} 167 | opener = urllib.request.build_opener( 168 | urllib.request.HTTPSHandler, 169 | urllib.request.ProxyHandler(proxies), 170 | urllib.request.HTTPRedirectHandler, 171 | ) 172 | req = urllib.request.Request( 173 | url, 174 | ) 175 | resp = opener.open(req) 176 | code = resp.getcode() 177 | if expected_code is not None and code != expected_code: 178 | raise urllib.error.HTTPError(resp.geturl(), code, resp.msg, resp.info(), resp) 179 | payload = resp.read() 180 | try: 181 | payload = gzip.decompress(payload) 182 | except OSError: 183 | pass 184 | finally: 185 | payload = payload.decode('utf-8') 186 | return code, payload 187 | 188 | 189 | def https_post(url, data=None, headers=None, proxies=None, expected_code=None): 190 | """Sends an HTTPS POST request; returns the HTTP status code and the 191 | decoded response payload. 192 | 193 | By default, HTTP 301, 302 and 303 are followed; all other non-2XX 194 | responses result in a urllib.error.HTTPError. If expected_code is 195 | supplied, a urllib.error.HTTPError is raised unless the status code 196 | matches expected_code. 197 | """ 198 | data = data or {} 199 | headers = headers or {} 200 | proxies = {'https': proxies['https']} if proxies.get('https') else {} 201 | opener = urllib.request.build_opener( 202 | urllib.request.HTTPSHandler, 203 | urllib.request.ProxyHandler(proxies), 204 | urllib.request.HTTPRedirectHandler, 205 | ) 206 | req = urllib.request.Request( 207 | url, 208 | data=urllib.parse.urlencode(data).encode('ascii'), 209 | headers=headers, 210 | ) 211 | resp = opener.open(req) 212 | code = resp.getcode() 213 | if expected_code is not None and code != expected_code: 214 | raise urllib.error.HTTPError(resp.geturl(), code, resp.msg, resp.info(), resp) 215 | payload = resp.read() 216 | try: 217 | payload = gzip.decompress(payload) 218 | except OSError: 219 | pass 220 | finally: 221 | payload = payload.decode('utf-8') 222 | return code, payload 223 | 224 | 225 | def unwrap(text): 226 | """Unwrap text.""" 227 | lines = text.split('\n') 228 | result = '' 229 | for i in range(len(lines) - 1): 230 | result += lines[i] 231 | if not lines[i]: 232 | # Paragraph break 233 | result += '\n\n' 234 | elif lines[i + 1]: 235 | # Next line is not paragraph break, add space 236 | result += ' ' 237 | # Handle last line 238 | result += lines[-1] if lines[-1] else '\n' 239 | return result 240 | 241 | 242 | def check_stdout_encoding(): 243 | """Make sure stdout encoding is utf-8. 244 | 245 | If not, print error message and instructions, then exit with 246 | status 1. 247 | 248 | This function is a no-op on win32 because encoding on win32 is 249 | messy, and let's just hope for the best. /s 250 | """ 251 | if sys.platform == 'win32': 252 | return 253 | 254 | # Use codecs.lookup to resolve text encoding alias 255 | encoding = codecs.lookup(sys.stdout.encoding).name 256 | if encoding != 'utf-8': 257 | locale_lang, locale_encoding = locale.getlocale() 258 | if locale_lang is None: 259 | locale_lang = '' 260 | if locale_encoding is None: 261 | locale_encoding = '' 262 | ioencoding = os.getenv('PYTHONIOENCODING', 'not set') 263 | sys.stderr.write(unwrap(textwrap.dedent("""\ 264 | stdout encoding '{encoding}' detected. ddgr requires utf-8 to 265 | work properly. The wrong encoding may be due to a non-UTF-8 266 | locale or an improper PYTHONIOENCODING. (For the record, your 267 | locale language is {locale_lang} and locale encoding is 268 | {locale_encoding}; your PYTHONIOENCODING is {ioencoding}.) 269 | 270 | Please set a UTF-8 locale (e.g., en_US.UTF-8) or set 271 | PYTHONIOENCODING to utf-8. 272 | """.format( 273 | encoding=encoding, 274 | locale_lang=locale_lang, 275 | locale_encoding=locale_encoding, 276 | ioencoding=ioencoding, 277 | )))) 278 | sys.exit(1) 279 | 280 | 281 | def printerr(msg): 282 | """Print message, verbatim, to stderr. 283 | 284 | ``msg`` could be any stringifiable value. 285 | """ 286 | print(msg, file=sys.stderr) 287 | 288 | 289 | # Monkeypatch textwrap for CJK wide characters. 290 | def monkeypatch_textwrap_for_cjk(): 291 | try: 292 | if textwrap.wrap.patched: 293 | return 294 | except AttributeError: 295 | pass 296 | psl_textwrap_wrap = textwrap.wrap 297 | 298 | def textwrap_wrap(text, width=70, **kwargs): 299 | width = max(width, 2) 300 | # We first add a U+0000 after each East Asian Fullwidth or East 301 | # Asian Wide character, then fill to width - 1 (so that if a NUL 302 | # character ends up on a new line, we still have one last column 303 | # to spare for the preceding wide character). Finally we strip 304 | # all the NUL characters. 305 | # 306 | # East Asian Width: https://www.unicode.org/reports/tr11/ 307 | return [ 308 | line.replace('\0', '') 309 | for line in psl_textwrap_wrap( 310 | ''.join( 311 | ch + '\0' if unicodedata.east_asian_width(ch) in ('F', 'W') else ch 312 | for ch in unicodedata.normalize('NFC', text) 313 | ), 314 | width=width - 1, 315 | **kwargs 316 | ) 317 | ] 318 | 319 | def textwrap_fill(text, width=70, **kwargs): 320 | return '\n'.join(textwrap_wrap(text, width=width, **kwargs)) 321 | textwrap.wrap = textwrap_wrap 322 | textwrap.fill = textwrap_fill 323 | textwrap.wrap.patched = True 324 | textwrap.fill.patched = True 325 | 326 | 327 | monkeypatch_textwrap_for_cjk() 328 | 329 | 330 | # Classes 331 | 332 | class DdgUrl: 333 | """ 334 | This class constructs the DuckDuckGo Search/News URL. 335 | 336 | This class is modeled on urllib.parse.ParseResult for familiarity, 337 | which means it supports reading of all six attributes -- scheme, 338 | netloc, path, params, query, fragment -- of 339 | urllib.parse.ParseResult, as well as the geturl() method. 340 | 341 | However, the attributes (properties) and methods listed below should 342 | be the preferred methods of access to this class. 343 | 344 | Parameters 345 | ---------- 346 | opts : dict or argparse.Namespace, optional 347 | See the ``opts`` parameter of `update`. 348 | 349 | Other Parameters 350 | ---------------- 351 | See "Other Parameters" of `update`. 352 | 353 | Attributes 354 | ---------- 355 | hostname : str 356 | Read-write property. 357 | keywords : str or list of strs 358 | Read-write property. 359 | news : bool 360 | Read-only property. 361 | url : str 362 | Read-only property. 363 | 364 | Methods 365 | ------- 366 | full() 367 | update(opts=None, **kwargs) 368 | set_queries(**kwargs) 369 | unset_queries(*args) 370 | next_page() 371 | prev_page() 372 | first_page() 373 | 374 | """ 375 | 376 | def __init__(self, opts=None, **kwargs): 377 | self.scheme = 'https' 378 | # self.netloc is a calculated property 379 | self.path = '/html/' 380 | self.params = '' 381 | # self.query is a calculated property 382 | self.fragment = '' 383 | 384 | self._duration = '' # duration as day, week, month or unlimited 385 | self._region = '' # Region code 386 | self._qrycnt = 0 # Number of search results fetched in most recent query 387 | self._curindex = 1 # Index of total results in pages fetched so far + 1 388 | self._page = 0 # Current page number 389 | self._keywords = [] 390 | self._sites = None 391 | self._safe = 1 # Safe search parameter value 392 | self.np_prev = '' # nextParams from last html page Previous button 393 | self.np_next = '' # nextParams from last html page Next button 394 | self.vqd = '' # vqd parameter (from next/prev button) 395 | self._query_dict = { 396 | } 397 | self.update(opts, **kwargs) 398 | 399 | def __str__(self): 400 | return self.url 401 | 402 | @property 403 | def url(self): 404 | """The full DuckDuckGo URL you want.""" 405 | return self.full() 406 | 407 | @property 408 | def hostname(self): 409 | """The hostname.""" 410 | return self.netloc 411 | 412 | @hostname.setter 413 | def hostname(self, hostname): 414 | self.netloc = hostname 415 | 416 | @property 417 | def keywords(self): 418 | """The keywords, either a str or a list of strs.""" 419 | return self._keywords 420 | 421 | @keywords.setter 422 | def keywords(self, keywords): 423 | self._keywords = keywords 424 | 425 | @property 426 | def news(self): 427 | """Whether the URL is for DuckDuckGo News.""" 428 | return 'tbm' in self._query_dict and self._query_dict['tbm'] == 'nws' 429 | 430 | def full(self): 431 | """Return the full URL. 432 | 433 | Returns 434 | ------- 435 | str 436 | 437 | """ 438 | q = '' 439 | if self._keywords: 440 | if isinstance(self._keywords, list): 441 | q += '+'.join(list(self._keywords)) 442 | else: 443 | q += self._keywords 444 | 445 | url = (self.scheme + ':') if self.scheme else '' 446 | url += '//' + self.netloc + '/?q=' + q 447 | return url 448 | 449 | def update(self, opts=None, **kwargs): 450 | """Update the URL with the given options. 451 | 452 | Parameters 453 | ---------- 454 | opts : dict or argparse.Namespace, optional 455 | Carries options that affect the DuckDuckGo Search/News URL. The 456 | list of currently recognized option keys with expected value 457 | types: 458 | 459 | keywords: str or list of strs 460 | num: int 461 | 462 | Other Parameters 463 | ---------------- 464 | kwargs 465 | The `kwargs` dict extends `opts`, that is, options can be 466 | specified either way, in `opts` or as individual keyword 467 | arguments. 468 | 469 | """ 470 | 471 | if opts is None: 472 | opts = {} 473 | if hasattr(opts, '__dict__'): 474 | opts = opts.__dict__ 475 | opts.update(kwargs) 476 | 477 | if 'keywords' in opts: 478 | self._keywords = opts['keywords'] 479 | self._duration = opts['duration'] 480 | if 'region' in opts: 481 | self._region = opts['region'] 482 | if 'num' in opts: 483 | self._qrycnt = 0 484 | if 'sites' in opts: 485 | self._sites = opts['sites'] 486 | if 'unsafe' in opts and opts['unsafe']: 487 | self._safe = -2 488 | 489 | def set_queries(self, **kwargs): 490 | """Forcefully set queries outside the normal `update` mechanism. 491 | 492 | Other Parameters 493 | ---------------- 494 | kwargs 495 | Arbitrary key value pairs to be set in the query string. All 496 | keys and values should be stringifiable. 497 | 498 | Note that certain keys, e.g., ``q``, have their values 499 | constructed on the fly, so setting those has no actual 500 | effect. 501 | 502 | """ 503 | for k, v in kwargs.items(): 504 | self._query_dict[k] = v 505 | 506 | def unset_queries(self, *args): 507 | """Forcefully unset queries outside the normal `update` mechanism. 508 | 509 | Other Parameters 510 | ---------------- 511 | args 512 | Arbitrary keys to be unset. No exception is raised if a key 513 | does not exist in the first place. 514 | 515 | Note that certain keys, e.g., ``q``, are always included in 516 | the resulting URL, so unsetting those has no actual effect. 517 | 518 | """ 519 | for k in args: 520 | self._query_dict.pop(k, None) 521 | 522 | def next_page(self): 523 | """Navigate to the next page.""" 524 | self._page = self._page + 1 525 | 526 | if self._curindex > 0: 527 | self._curindex = self._curindex + self._qrycnt 528 | else: 529 | self._curindex = -self._curindex 530 | 531 | def prev_page(self): 532 | """Navigate to the previous page. 533 | 534 | Raises 535 | ------ 536 | ValueError 537 | If already at the first page (``page=0`` in the current 538 | query string). 539 | 540 | """ 541 | if self._page == 0: 542 | raise ValueError('Already at the first page.') 543 | 544 | self._page = self._page - 1 545 | 546 | if self._curindex > 0: 547 | self._curindex = -self._curindex # A negative index is used when fetching previous page 548 | else: 549 | self._curindex = self._curindex + self._qrycnt 550 | 551 | def first_page(self): 552 | """Navigate to the first page. 553 | 554 | Raises 555 | ------ 556 | ValueError 557 | If already at the first page (``page=0`` in the current 558 | query string). 559 | 560 | """ 561 | if self._page == 0: 562 | raise ValueError('Already at the first page.') 563 | self._page = 0 564 | self._qrycnt = 0 565 | self._curindex = 1 566 | 567 | @property 568 | def netloc(self): 569 | """The hostname.""" 570 | return 'duckduckgo.com' 571 | 572 | def query(self): 573 | """The query string.""" 574 | qd = {} 575 | qd.update(self._query_dict) 576 | qd['duration'] = self._duration 577 | qd['region'] = self._region 578 | qd['curindex'] = self._curindex 579 | qd['page'] = self._page 580 | qd['safe'] = self._safe 581 | if self._curindex < 0: 582 | qd['nextParams'] = self.np_prev 583 | else: 584 | qd['nextParams'] = self.np_next 585 | qd['vqd'] = self.vqd 586 | 587 | # Construct the q query 588 | q = '' 589 | keywords = self._keywords 590 | sites = self._sites 591 | if keywords: 592 | if isinstance(keywords, list): 593 | q += ' '.join(list(keywords)) 594 | else: 595 | q += keywords 596 | if sites: 597 | q += ' site:' + ','.join(urllib.parse.quote_plus(site) for site in sites) 598 | qd['q'] = q 599 | 600 | return qd 601 | 602 | def update_num(self, count): 603 | self._qrycnt = count 604 | 605 | 606 | class DdgAPIUrl: 607 | """ 608 | This class constructs the DuckDuckGo Instant Answer API URL. 609 | 610 | Attributes 611 | ---------- 612 | hostname : str 613 | Read-write property. 614 | keywords : str or list of strs 615 | Read-write property. 616 | url : str 617 | Read-only property. 618 | netloc : str 619 | Read-only property. 620 | 621 | Methods 622 | ------- 623 | full() 624 | 625 | """ 626 | 627 | def __init__(self, keywords): 628 | self.scheme = 'https' 629 | self.path = '/' 630 | self.params = '' 631 | self._format = 'format=json' 632 | self._keywords = keywords 633 | 634 | def __str__(self): 635 | return self.url 636 | 637 | @property 638 | def url(self): 639 | """The full DuckDuckGo URL you want.""" 640 | return self.full() 641 | 642 | @property 643 | def hostname(self): 644 | """The hostname.""" 645 | return self.netloc 646 | 647 | @hostname.setter 648 | def hostname(self, hostname): 649 | self.netloc = hostname 650 | 651 | @property 652 | def keywords(self): 653 | """The keywords, either a str or a list of strs.""" 654 | return self._keywords 655 | 656 | @keywords.setter 657 | def keywords(self, keywords): 658 | self._keywords = keywords 659 | 660 | @property 661 | def netloc(self): 662 | """The hostname.""" 663 | return 'api.duckduckgo.com' 664 | 665 | def full(self): 666 | """Return the full URL. 667 | 668 | Returns 669 | ------- 670 | str 671 | 672 | """ 673 | q = '' 674 | if self._keywords: 675 | if isinstance(self._keywords, list): 676 | q += '+'.join(list(self._keywords)) 677 | else: 678 | q += self._keywords 679 | 680 | url = (self.scheme + ':') if self.scheme else '' 681 | url += '//' + self.netloc + '/?q=' + q + "&" + self._format 682 | return url 683 | 684 | 685 | class DDGConnectionError(Exception): 686 | pass 687 | 688 | 689 | class DdgConnection: 690 | """ 691 | This class facilitates connecting to and fetching from DuckDuckGo. 692 | 693 | Parameters 694 | ---------- 695 | See http.client.HTTPSConnection for documentation of the 696 | parameters. 697 | 698 | Raises 699 | ------ 700 | DDGConnectionError 701 | 702 | Methods 703 | ------- 704 | fetch_page(url) 705 | 706 | """ 707 | 708 | def __init__(self, proxy=None, ua=''): 709 | self._u = 'https://html.duckduckgo.com/html' 710 | 711 | self._proxies = { 712 | 'https': proxy if proxy is not None else (os.getenv('https_proxy') 713 | if os.getenv('https_proxy') is not None 714 | else os.getenv('HTTPS_PROXY')) 715 | } 716 | self._ua = ua 717 | 718 | def fetch_page(self, url): 719 | """Fetch a URL. 720 | 721 | Allows one reconnection and one redirection before failing and 722 | raising DDGConnectionError. 723 | 724 | Parameters 725 | ---------- 726 | url : str 727 | The URL to fetch, relative to the host. 728 | 729 | Raises 730 | ------ 731 | DDGConnectionError 732 | When not getting HTTP 200 even after the allowed one 733 | reconnection and/or one redirection, or when DuckDuckGo is 734 | blocking query due to unsual activity. 735 | 736 | Returns 737 | ------- 738 | str 739 | Response payload, gunzipped (if applicable) and decoded (in UTF-8). 740 | 741 | """ 742 | dic = url.query() 743 | page = dic['page'] 744 | LOGDBG('q:%s, region:%s, page:%d, curindex:%d, safe:%d', dic['q'], dic['region'], page, dic['curindex'], dic['safe']) 745 | LOGDBG('nextParams:%s', dic['nextParams']) 746 | LOGDBG('vqd:%s', dic['vqd']) 747 | LOGDBG('proxy:%s', self._proxies) 748 | LOGDBG('ua:%s', self._ua) 749 | 750 | try: 751 | if page == 0: 752 | _, r = https_post(self._u, 753 | headers={ 754 | 'Accept-Encoding': 'gzip', 755 | 'User-Agent': self._ua, 756 | 'DNT': '1', 757 | }, 758 | data={ 759 | 'q': dic['q'], 760 | 'b': '', 761 | 'df': dic['duration'], 762 | 'kf': '-1', 763 | 'kh': '1', 764 | 'kl': dic['region'], 765 | 'kp': dic['safe'], 766 | 'k1': '-1', 767 | }, 768 | proxies=self._proxies, 769 | expected_code=200) 770 | else: 771 | _, r = https_post(self._u, 772 | headers={ 773 | 'Accept-Encoding': 'gzip', 774 | 'User-Agent': self._ua, 775 | 'DNT': '1', 776 | }, 777 | data={ 778 | 'q': dic['q'], # The query string 779 | 's': str(50 * (page - 1) + 30), # Page index 780 | 'nextParams': dic['nextParams'], # nextParams from last visited page 781 | 'v': 'l', 782 | 'o': 'json', 783 | 'dc': str(dic['curindex']), # Start from total fetched result index 784 | 'df': dic['duration'], 785 | 'api': '/d.js', 786 | 'kf': '-1', # Disable favicons 787 | 'kh': '1', # HTTPS always ON 788 | 'kl': dic['region'], # Region code 789 | 'kp': dic['safe'], # Safe search 790 | 'k1': '-1', # Advertisements off 791 | 'vqd': dic['vqd'], # vqd string from button 792 | }, 793 | proxies=self._proxies, 794 | expected_code=200) 795 | except Exception as e: 796 | LOGERR(e) 797 | return None 798 | 799 | return r 800 | 801 | 802 | def annotate_tag(annotated_starttag_handler): 803 | # See parser logic within the DdgParser class for documentation. 804 | # 805 | # annotated_starttag_handler(self, tag: str, attrsdict: dict) -> annotation 806 | # Returns: HTMLParser.handle_starttag(self, tag: str, attrs: list) -> None 807 | 808 | def handler(self, tag, attrs): 809 | attrs = dict(attrs) 810 | annotation = annotated_starttag_handler(self, tag, attrs) 811 | self.insert_annotation(tag, annotation) 812 | 813 | return handler 814 | 815 | 816 | def retrieve_tag_annotation(annotated_endtag_handler): 817 | # See parser logic within the DdgParser class for documentation. 818 | # 819 | # annotated_endtag_handler(self, tag: str, annotation) -> None 820 | # Returns: HTMLParser.handle_endtag(self, tag: str) -> None 821 | 822 | def handler(self, tag): 823 | try: 824 | annotation = self.tag_annotations[tag].pop() 825 | except IndexError: 826 | # Malformed HTML -- more close tags than open tags 827 | annotation = None 828 | annotated_endtag_handler(self, tag, annotation) 829 | 830 | return handler 831 | 832 | 833 | class DdgParser(html.parser.HTMLParser): 834 | """The members of this class parse the result HTML 835 | page fetched from DuckDuckGo server for a query. 836 | 837 | The custom parser looks for tags enclosing search 838 | results and extracts the URL, title and text for 839 | each search result. 840 | 841 | After parsing the complete HTML page results are 842 | returned in a list of objects of class Result. 843 | """ 844 | 845 | # Parser logic: 846 | # 847 | # - Guiding principles: 848 | # 849 | # 1. Tag handlers are contextual; 850 | # 851 | # 2. Contextual starttag and endtag handlers should come in pairs 852 | # and have a clear hierarchy; 853 | # 854 | # 3. starttag handlers should only yield control to a pair of 855 | # child handlers (that is, one level down the hierarchy), and 856 | # correspondingly, endtag handlers should only return control 857 | # to the parent (that is, the pair of handlers that gave it 858 | # control in the first place). 859 | # 860 | # Principle 3 is meant to enforce a (possibly implicit) stack 861 | # structure and thus prevent careless jumps that result in what's 862 | # essentially spaghetti code with liberal use of GOTOs. 863 | # 864 | # - HTMLParser.handle_endtag gives us a bare tag name without 865 | # context, which is not good for enforcing principle 3 when we 866 | # have, say, nested div tags. 867 | # 868 | # In order to precisely identify the matching opening tag, we 869 | # maintain a stack for each tag name with *annotations*. Important 870 | # opening tags (e.g., the ones where child handlers are 871 | # registered) can be annotated so that when we can watch for the 872 | # annotation in the endtag handler, and when the appropriate 873 | # annotation is popped, we perform the corresponding action (e.g., 874 | # switch back to old handlers). 875 | # 876 | # To facilitate this, each starttag handler is decorated with 877 | # @annotate_tag, which accepts a return value that is the 878 | # annotation (None by default), and additionally converts attrs to 879 | # a dict, which is much easier to work with; and each endtag 880 | # handler is decorated with @retrieve_tag_annotation which sends 881 | # an additional parameter that is the retrieved annotation to the 882 | # handler. 883 | # 884 | # Note that some of our tag annotation stacks leak over time: this 885 | # happens to tags like and
which are not 886 | # closed. However, these tags play no structural role, and come 887 | # only in small quantities, so it's not really a problem. 888 | # 889 | # - All textual data (result title, result abstract, etc.) are 890 | # processed through a set of shared handlers. These handlers store 891 | # text in a shared buffer self.textbuf which can be retrieved and 892 | # cleared at appropriate times. 893 | # 894 | # Data (including charrefs and entityrefs) are ignored initially, 895 | # and when data needs to be recorded, the start_populating_textbuf 896 | # method is called to register the appropriate data, charref and 897 | # entityref handlers so that they append to self.textbuf. When 898 | # recording ends, pop_textbuf should be called to extract the text 899 | # and clear the buffer. stop_populating_textbuf returns the 900 | # handlers to their pristine state (ignoring data). 901 | # 902 | # Methods: 903 | # - start_populating_textbuf(self, data_transformer: Callable[[str], str]) -> None 904 | # - pop_textbuf(self) -> str 905 | # - stop_populating_textbuf(self) -> None 906 | # 907 | # - Outermost starttag and endtag handler methods: root_*. The whole 908 | # parser starts and ends in this state. 909 | # 910 | # - Each result is wrapped in a
tag with class "links_main". 911 | # 912 | # 913 | # 915 | # 916 | # - For each result, the first

tag with class "result__title" contains the 917 | # hyperlinked title. 918 | # 919 | # 920 | #

921 | #

922 | # 923 | # - Abstracts are within the scope of
tag with class "links_main". Links in 924 | # abstract are ignored as they are available within

tag. 925 | # 926 | # 927 | # 928 | # 929 | # 930 | # - Each title looks like 931 | # 932 | #

933 | # 934 | # 936 | # result title 937 | # 939 | # file type (e.g. [PDF]) 940 | # 942 | # 944 | #

945 | 946 | def __init__(self, offset=0): 947 | html.parser.HTMLParser.__init__(self) 948 | 949 | self.title = '' 950 | self.url = '' 951 | self.abstract = '' 952 | self.filetype = '' 953 | 954 | self.results = [] 955 | self.index = offset 956 | self.textbuf = '' 957 | self.click_result = '' 958 | self.tag_annotations = {} 959 | self.np_prev_button = '' 960 | self.np_next_button = '' 961 | self.npfound = False # First next params found 962 | self.set_handlers_to('root') 963 | self.vqd = '' # vqd returned from button, required for search query to get next set of results 964 | 965 | # Tag handlers 966 | 967 | @annotate_tag 968 | def root_start(self, tag, attrs): 969 | if tag == 'div': 970 | if 'zci__result' in self.classes(attrs): 971 | self.start_populating_textbuf() 972 | return 'click_result' 973 | 974 | if 'links_main' in self.classes(attrs): 975 | # Initialize result field registers 976 | self.title = '' 977 | self.url = '' 978 | self.abstract = '' 979 | self.filetype = '' 980 | 981 | self.set_handlers_to('result') 982 | return 'result' 983 | 984 | if 'nav-link' in self.classes(attrs): 985 | self.set_handlers_to('input') 986 | return 'input' 987 | return '' 988 | 989 | @retrieve_tag_annotation 990 | def root_end(self, tag, annotation): 991 | if annotation == 'click_result': 992 | self.stop_populating_textbuf() 993 | self.click_result = self.pop_textbuf() 994 | self.set_handlers_to('root') 995 | 996 | @annotate_tag 997 | def result_start(self, tag, attrs): 998 | if tag == 'h2' and 'result__title' in self.classes(attrs): 999 | self.set_handlers_to('title') 1000 | return 'title' 1001 | 1002 | if tag == 'a' and 'result__snippet' in self.classes(attrs) and 'href' in attrs: 1003 | self.start_populating_textbuf() 1004 | return 'abstract' 1005 | 1006 | return '' 1007 | 1008 | @retrieve_tag_annotation 1009 | def result_end(self, tag, annotation): 1010 | if annotation == 'abstract': 1011 | self.stop_populating_textbuf() 1012 | self.abstract = self.pop_textbuf() 1013 | elif annotation == 'result': 1014 | if self.url: 1015 | self.index += 1 1016 | result = Result(self.index, self.title, self.url, self.abstract, None) 1017 | self.results.append(result) 1018 | self.set_handlers_to('root') 1019 | 1020 | @annotate_tag 1021 | def title_start(self, tag, attrs): 1022 | if tag == 'span': 1023 | # Print a space after the filetype indicator 1024 | self.start_populating_textbuf(lambda text: '[' + text + ']') 1025 | return 'title_filetype' 1026 | 1027 | if tag == 'a' and 'href' in attrs: 1028 | # Skip 'News for', 'Images for' search links 1029 | if attrs['href'].startswith('/search'): 1030 | return '' 1031 | 1032 | self.url = attrs['href'] 1033 | try: 1034 | start = self.url.index('?q=') + len('?q=') 1035 | end = self.url.index('&sa=', start) 1036 | self.url = urllib.parse.unquote_plus(self.url[start:end]) 1037 | except ValueError: 1038 | pass 1039 | self.start_populating_textbuf() 1040 | return 'title_link' 1041 | 1042 | return '' 1043 | 1044 | @retrieve_tag_annotation 1045 | def title_end(self, tag, annotation): 1046 | if annotation == 'title_filetype': 1047 | self.stop_populating_textbuf() 1048 | self.filetype = self.pop_textbuf() 1049 | self.start_populating_textbuf() 1050 | elif annotation == 'title_link': 1051 | self.stop_populating_textbuf() 1052 | self.title = self.pop_textbuf() 1053 | if self.filetype != '': 1054 | self.title = self.filetype + self.title 1055 | elif annotation == 'title': 1056 | self.set_handlers_to('result') 1057 | 1058 | @annotate_tag 1059 | def abstract_start(self, tag, attrs): 1060 | if tag == 'span' and 'st' in self.classes(attrs): 1061 | self.start_populating_textbuf() 1062 | return 'abstract_text' 1063 | return '' 1064 | 1065 | @retrieve_tag_annotation 1066 | def abstract_end(self, tag, annotation): 1067 | if annotation == 'abstract_text': 1068 | self.stop_populating_textbuf() 1069 | self.abstract = self.pop_textbuf() 1070 | elif annotation == 'abstract': 1071 | self.set_handlers_to('result') 1072 | 1073 | @annotate_tag 1074 | def input_start(self, tag, attrs): 1075 | if tag == 'input' and 'name' in attrs: 1076 | if attrs['name'] == 'nextParams': 1077 | # The previous button always shows before next button 1078 | # If there's only 1 button (page 1), it's the next button 1079 | if self.npfound is True: 1080 | self.np_prev_button = self.np_next_button 1081 | else: 1082 | self.npfound = True 1083 | 1084 | self.np_next_button = attrs['value'] 1085 | return 1086 | if attrs['name'] == 'vqd' and attrs['value'] != '': 1087 | # vqd required to be passed for next/previous search page 1088 | self.vqd = attrs['value'] 1089 | return 1090 | 1091 | @retrieve_tag_annotation 1092 | def input_end(self, tag, annotation): 1093 | return 1094 | 1095 | # Generic methods 1096 | 1097 | # Set handle_starttag to SCOPE_start, and handle_endtag to SCOPE_end. 1098 | def set_handlers_to(self, scope): 1099 | self.handle_starttag = getattr(self, scope + '_start') 1100 | self.handle_endtag = getattr(self, scope + '_end') 1101 | 1102 | def insert_annotation(self, tag, annotation): 1103 | if tag not in self.tag_annotations: 1104 | self.tag_annotations[tag] = [] 1105 | self.tag_annotations[tag].append(annotation) 1106 | 1107 | def start_populating_textbuf(self, data_transformer=None): 1108 | if data_transformer is None: 1109 | # Record data verbatim 1110 | self.handle_data = self.record_data 1111 | else: 1112 | def record_transformed_data(data): 1113 | self.textbuf += data_transformer(data) 1114 | 1115 | self.handle_data = record_transformed_data 1116 | 1117 | self.handle_entityref = self.record_entityref 1118 | self.handle_charref = self.record_charref 1119 | 1120 | def pop_textbuf(self): 1121 | text = self.textbuf 1122 | self.textbuf = '' 1123 | return text 1124 | 1125 | def stop_populating_textbuf(self): 1126 | self.handle_data = lambda data: None 1127 | self.handle_entityref = lambda ref: None 1128 | self.handle_charref = lambda ref: None 1129 | 1130 | def record_data(self, data): 1131 | self.textbuf += data 1132 | 1133 | def record_entityref(self, ref): 1134 | try: 1135 | self.textbuf += chr(html.entities.name2codepoint[ref]) 1136 | except KeyError: 1137 | # Entity name not found; most likely rather sloppy HTML 1138 | # where a literal ampersand is not escaped; For instance, 1139 | # containing the following tag 1140 | # 1141 | #

expected market return s&p 500

1142 | # 1143 | # where &p is interpreted by HTMLParser as an entity (this 1144 | # behaviour seems to be specific to Python 2.7). 1145 | self.textbuf += '&' + ref 1146 | 1147 | def record_charref(self, ref): 1148 | if ref.startswith('x'): 1149 | char = chr(int(ref[1:], 16)) 1150 | else: 1151 | char = chr(int(ref)) 1152 | self.textbuf += char 1153 | 1154 | @staticmethod 1155 | def classes(attrs): 1156 | """Get tag's classes from its attribute dict.""" 1157 | return attrs.get('class', '').split() 1158 | 1159 | def error(self, message): 1160 | raise NotImplementedError("subclasses of ParserBase must override error()") 1161 | 1162 | 1163 | Colors = collections.namedtuple('Colors', 'index, title, url, metadata, abstract, prompt, reset') 1164 | 1165 | 1166 | class Result: 1167 | """ 1168 | Container for one search result, with output helpers. 1169 | 1170 | Parameters 1171 | ---------- 1172 | index : int or str 1173 | title : str 1174 | url : str 1175 | abstract : str 1176 | metadata : str, optional 1177 | Only applicable to DuckDuckGo News results, with publisher name and 1178 | publishing time. 1179 | 1180 | Attributes 1181 | ---------- 1182 | index : str 1183 | title : str 1184 | url : str 1185 | abstract : str 1186 | metadata : str or None 1187 | 1188 | Class Variables 1189 | --------------- 1190 | colors : str 1191 | 1192 | Methods 1193 | ------- 1194 | print() 1195 | jsonizable_object() 1196 | urltable() 1197 | 1198 | """ 1199 | 1200 | # Class variables 1201 | colors = None 1202 | urlexpand = False 1203 | 1204 | def __init__(self, index, title, url, abstract, metadata=None): 1205 | index = str(index) 1206 | self.index = index 1207 | self.title = title 1208 | self.url = url 1209 | self.abstract = abstract 1210 | self.metadata = metadata 1211 | 1212 | self._urltable = {index: url} 1213 | 1214 | def _print_title_and_url(self, index, title, url): 1215 | indent = INDENT - 2 1216 | colors = self.colors 1217 | 1218 | if not self.urlexpand: 1219 | url = '[' + urllib.parse.urlparse(url).netloc + ']' 1220 | 1221 | if colors: 1222 | # Adjust index to print result index clearly 1223 | print(" %s%-*s%s" % (colors.index, indent, index + '.', colors.reset), end='') 1224 | if not self.urlexpand: 1225 | print(' ' + colors.title + title + colors.reset + ' ' + colors.url + url + colors.reset) 1226 | else: 1227 | print(' ' + colors.title + title + colors.reset) 1228 | print(' ' * (INDENT) + colors.url + url + colors.reset) 1229 | else: 1230 | if self.urlexpand: 1231 | print(' %-*s %s' % (indent, index + '.', title)) 1232 | print(' %s%s' % (' ' * (indent + 1), url)) 1233 | else: 1234 | print(' %-*s %s %s' % (indent, index + '.', title, url)) 1235 | 1236 | def _print_metadata_and_abstract(self, abstract, metadata=None): 1237 | colors = self.colors 1238 | try: 1239 | columns, _ = os.get_terminal_size() 1240 | except OSError: 1241 | columns = 0 1242 | 1243 | if metadata: 1244 | if colors: 1245 | print(' ' * INDENT + colors.metadata + metadata + colors.reset) 1246 | else: 1247 | print(' ' * INDENT + metadata) 1248 | 1249 | if colors: 1250 | print(colors.abstract, end='') 1251 | if columns > INDENT + 1: 1252 | # Try to fill to columns 1253 | fillwidth = columns - INDENT - 1 1254 | for line in textwrap.wrap(abstract.replace('\n', ''), width=fillwidth): 1255 | print('%s%s' % (' ' * INDENT, line)) 1256 | print('') 1257 | else: 1258 | print('%s\n' % abstract.replace('\n', ' ')) 1259 | if colors: 1260 | print(colors.reset, end='') 1261 | 1262 | def print(self): 1263 | """Print the result entry.""" 1264 | 1265 | self._print_title_and_url(self.index, self.title, self.url) 1266 | self._print_metadata_and_abstract(self.abstract, metadata=self.metadata) 1267 | 1268 | def print_paginated(self, display_index): 1269 | """Print the result entry with custom index.""" 1270 | 1271 | self._print_title_and_url(display_index, self.title, self.url) 1272 | self._print_metadata_and_abstract(self.abstract, metadata=self.metadata) 1273 | 1274 | def jsonizable_object(self): 1275 | """Return a JSON-serializable dict representing the result entry.""" 1276 | obj = { 1277 | 'title': self.title, 1278 | 'url': self.url, 1279 | 'abstract': self.abstract 1280 | } 1281 | if self.metadata: 1282 | obj['metadata'] = self.metadata 1283 | return obj 1284 | 1285 | def urltable(self): 1286 | """Return a index-to-URL table for the current result. 1287 | 1288 | Normally, the table contains only a single entry, but when the result 1289 | contains sitelinks, all sitelinks are included in this table. 1290 | 1291 | Returns 1292 | ------- 1293 | dict 1294 | A dict mapping indices (strs) to URLs (also strs). 1295 | 1296 | """ 1297 | return self._urltable 1298 | 1299 | 1300 | class DdgCmdException(Exception): 1301 | pass 1302 | 1303 | 1304 | class NoKeywordsException(DdgCmdException): 1305 | pass 1306 | 1307 | 1308 | def require_keywords(method): 1309 | # Require keywords to be set before we run a DdgCmd method. If 1310 | # no keywords have been set, raise a NoKeywordsException. 1311 | @functools.wraps(method) 1312 | def enforced_method(self, *args, **kwargs): 1313 | if not self.keywords: 1314 | raise NoKeywordsException('No keywords.') 1315 | method(self, *args, **kwargs) 1316 | 1317 | return enforced_method 1318 | 1319 | 1320 | def no_argument(method): 1321 | # Normalize a do_* method of DdgCmd that takes no argument to 1322 | # one that takes an arg, but issue a warning when an nonempty 1323 | # argument is given. 1324 | @functools.wraps(method) 1325 | def enforced_method(self, arg): 1326 | if arg: 1327 | method_name = arg.__name__ 1328 | command_name = method_name[3:] if method_name.startswith('do_') else method_name 1329 | LOGGER.warning("Argument to the '%s' command ignored.", command_name) 1330 | method(self) 1331 | 1332 | return enforced_method 1333 | 1334 | 1335 | class DdgCmd: 1336 | """ 1337 | Command line interpreter and executor class for ddgr. 1338 | 1339 | Inspired by PSL cmd.Cmd. 1340 | 1341 | Parameters 1342 | ---------- 1343 | opts : argparse.Namespace 1344 | Options and/or arguments. 1345 | 1346 | Attributes 1347 | ---------- 1348 | options : argparse.Namespace 1349 | Options that are currently in effect. Read-only attribute. 1350 | keywords : str or list or strs 1351 | Current keywords. Read-only attribute 1352 | 1353 | Methods 1354 | ------- 1355 | fetch() 1356 | display_instant_answer(json_output=False) 1357 | display_results(prelude='\n', json_output=False) 1358 | fetch_and_display(prelude='\n', json_output=False) 1359 | read_next_command() 1360 | help() 1361 | cmdloop() 1362 | 1363 | """ 1364 | 1365 | def __init__(self, opts, ua): 1366 | super().__init__() 1367 | self.cmd = '' 1368 | self.index = 0 1369 | self._opts = opts 1370 | 1371 | self._ddg_url = DdgUrl(opts) 1372 | proxy = opts.proxy if hasattr(opts, 'proxy') else None 1373 | self._conn = DdgConnection(proxy=proxy, ua=ua) 1374 | 1375 | self.results = [] 1376 | self.instant_answer = '' 1377 | self._urltable = {} 1378 | 1379 | colors = self.colors 1380 | message = 'ddgr (? for help)' 1381 | self.prompt = ((colors.prompt + message + colors.reset + ' ') 1382 | if (colors and os.getenv('DISABLE_PROMPT_COLOR') is None) else (message + ': ')) 1383 | 1384 | @property 1385 | def options(self): 1386 | """Current options.""" 1387 | return self._opts 1388 | 1389 | @property 1390 | def keywords(self): 1391 | """Current keywords.""" 1392 | return self._ddg_url.keywords 1393 | 1394 | @require_keywords 1395 | def fetch(self): 1396 | """Fetch a page and parse for results. 1397 | 1398 | Results are stored in ``self.results``. 1399 | Instant answer is stored in ``self.instant_answer``. 1400 | 1401 | Raises 1402 | ------ 1403 | DDGConnectionError 1404 | 1405 | See Also 1406 | -------- 1407 | fetch_and_display 1408 | 1409 | """ 1410 | # This method also sets self._urltable. 1411 | page = self._conn.fetch_page(self._ddg_url) 1412 | 1413 | if page is None: 1414 | return 1415 | 1416 | if LOGGER.isEnabledFor(logging.DEBUG): 1417 | fd, tmpfile = tempfile.mkstemp(prefix='ddgr-response-') 1418 | os.close(fd) 1419 | with open(tmpfile, 'w', encoding='utf-8') as fp: 1420 | fp.write(page) 1421 | LOGDBG("Response body written to '%s'.", tmpfile) 1422 | 1423 | if self._opts.num: 1424 | _index = len(self._urltable) 1425 | else: 1426 | _index = 0 1427 | self._urltable = {} 1428 | 1429 | parser = DdgParser(offset=_index) 1430 | parser.feed(page) 1431 | 1432 | if self._opts.num: 1433 | self.results.extend(parser.results) 1434 | else: 1435 | self.results = parser.results 1436 | 1437 | for r in parser.results: 1438 | self._urltable.update(r.urltable()) 1439 | 1440 | self._ddg_url.np_prev = parser.np_prev_button 1441 | self._ddg_url.np_next = parser.np_next_button 1442 | self._ddg_url.vqd = parser.vqd 1443 | 1444 | if parser.click_result: 1445 | self.instant_answer = parser.click_result.strip() 1446 | else: 1447 | self.instant_answer = '' 1448 | 1449 | self._ddg_url.update_num(len(parser.results)) 1450 | 1451 | @require_keywords 1452 | def display_instant_answer(self, json_output=False): 1453 | """Display instant answer stored in ``self.instant_answer``. 1454 | 1455 | Parameters 1456 | ---------- 1457 | json_output : bool, optional 1458 | Whether to dump results in JSON format. Default is False. 1459 | 1460 | See Also 1461 | -------- 1462 | fetch_and_display 1463 | 1464 | """ 1465 | if self.index == 0 and self.instant_answer and not json_output: 1466 | if self.colors: 1467 | print(self.colors.abstract) 1468 | 1469 | try: 1470 | columns, _ = os.get_terminal_size() 1471 | except OSError: 1472 | columns = 0 1473 | 1474 | fillwidth = columns - INDENT 1475 | for line in textwrap.wrap(self.instant_answer, width=fillwidth): 1476 | print('%s%s' % (' ' * INDENT, line)) 1477 | 1478 | if self.colors: 1479 | print(self.colors.reset, end='') 1480 | 1481 | if self._opts.reverse: 1482 | print('') 1483 | 1484 | @require_keywords 1485 | def display_results(self, prelude='\n', json_output=False): 1486 | """Display results stored in ``self.results``. 1487 | 1488 | Parameters 1489 | ---------- 1490 | See `fetch_and_display`. 1491 | 1492 | """ 1493 | 1494 | if self._opts.num: 1495 | results = self.results[self.index:(self.index + self._opts.num)] 1496 | else: 1497 | results = self.results 1498 | 1499 | if self._opts.reverse: 1500 | results.reverse() 1501 | 1502 | if json_output: 1503 | # JSON output 1504 | ## 1505 | # variables renamed, JSON structure changed and instant_answer 1506 | # added by @kometenstaub on 2023-08-03, 1507 | # same license as original code applies (GPL-3; see LICENSE file) 1508 | json_result = [r.jsonizable_object() for r in results] 1509 | results_object = { 1510 | "results": json_result, 1511 | } 1512 | if self.instant_answer: 1513 | results_object["instant_answer"] = self.instant_answer 1514 | print(json.dumps(results_object, indent=2, sort_keys=True, ensure_ascii=False)) 1515 | elif not results: 1516 | print('No results.', file=sys.stderr) 1517 | else: 1518 | sys.stderr.write(prelude) 1519 | 1520 | if self._opts.num: # Paginated output 1521 | for i, r in enumerate(results): 1522 | if self._opts.reverse: 1523 | r.print_paginated(str(len(results) - i)) 1524 | else: 1525 | r.print_paginated(str(i + 1)) 1526 | else: # Regular output 1527 | for r in results: 1528 | r.print() 1529 | 1530 | @require_keywords 1531 | def fetch_and_display(self, prelude='\n', json_output=False): 1532 | """Fetch a page and display results. 1533 | 1534 | Results are stored in ``self.results``. 1535 | 1536 | Parameters 1537 | ---------- 1538 | prelude : str, optional 1539 | A string that is written to stderr before showing actual results, 1540 | usually serving as a separator. Default is an empty line. 1541 | json_output : bool, optional 1542 | Whether to dump results in JSON format. Default is False. 1543 | 1544 | Raises 1545 | ------ 1546 | DDGConnectionError 1547 | 1548 | See Also 1549 | -------- 1550 | fetch 1551 | display_instant_answer 1552 | display_results 1553 | 1554 | """ 1555 | self.fetch() 1556 | if not self._opts.reverse: 1557 | self.display_instant_answer() 1558 | self.display_results(prelude=prelude, json_output=json_output) 1559 | else: 1560 | self.display_results(prelude=prelude, json_output=json_output) 1561 | self.display_instant_answer() 1562 | 1563 | def read_next_command(self): 1564 | """Show omniprompt and read user command line. 1565 | 1566 | Command line is always stripped, and each consecutive group of 1567 | whitespace is replaced with a single space character. If the 1568 | command line is empty after stripping, when ignore it and keep 1569 | reading. Exit with status 0 if we get EOF or an empty line 1570 | (pre-strip, that is, a raw ) twice in a row. 1571 | 1572 | The new command line (non-empty) is stored in ``self.cmd``. 1573 | 1574 | """ 1575 | enter_count = 0 1576 | while True: 1577 | try: 1578 | cmd = input(self.prompt) 1579 | except EOFError: 1580 | sys.exit(0) 1581 | 1582 | if not cmd: 1583 | enter_count += 1 1584 | if enter_count == 2: 1585 | # Double 1586 | sys.exit(0) 1587 | else: 1588 | enter_count = 0 1589 | 1590 | cmd = ' '.join(cmd.split()) 1591 | if cmd: 1592 | self.cmd = cmd 1593 | break 1594 | 1595 | @staticmethod 1596 | def help(): 1597 | DdgArgumentParser.print_omniprompt_help(sys.stderr) 1598 | printerr('') 1599 | 1600 | @require_keywords 1601 | @no_argument 1602 | def do_first(self): 1603 | if self._opts.num: 1604 | if self.index < self._opts.num: 1605 | print('Already at the first page.', file=sys.stderr) 1606 | else: 1607 | self.index = 0 1608 | self.display_results() 1609 | return 1610 | 1611 | try: 1612 | self._ddg_url.first_page() 1613 | except ValueError as e: 1614 | print(e, file=sys.stderr) 1615 | return 1616 | 1617 | self.fetch_and_display() 1618 | 1619 | def do_ddg(self, arg): 1620 | if self._opts.num: 1621 | self.index = 0 1622 | self.results = [] 1623 | self._urltable = {} 1624 | # Update keywords and reconstruct URL 1625 | self._opts.keywords = arg 1626 | self._ddg_url = DdgUrl(self._opts) 1627 | # If there is a Bang, let DuckDuckGo do the work 1628 | if arg[0] == '!' or (len(arg) > 1 and arg[1] == '!'): 1629 | open_url(self._ddg_url.full()) 1630 | else: 1631 | self.fetch_and_display() 1632 | 1633 | @require_keywords 1634 | @no_argument 1635 | def do_next(self): 1636 | if self._opts.num: 1637 | count = len(self.results) 1638 | if self._ddg_url._qrycnt == 0 and self.index >= count: 1639 | print('No results.', file=sys.stderr) 1640 | return 1641 | 1642 | self.index += self._opts.num 1643 | if count - self.index < self._opts.num: 1644 | self._ddg_url.next_page() 1645 | self.fetch_and_display() 1646 | else: 1647 | self.display_results() 1648 | elif self._ddg_url._qrycnt == 0: 1649 | # If no results were fetched last time, we have hit the last page already 1650 | print('No results.', file=sys.stderr) 1651 | else: 1652 | self._ddg_url.next_page() 1653 | self.fetch_and_display() 1654 | 1655 | def handle_range(self, nav, low, high): 1656 | try: 1657 | if self._opts.num: 1658 | vals = [int(x) + self.index for x in nav.split('-')] 1659 | else: 1660 | vals = [int(x) for x in nav.split('-')] 1661 | 1662 | if len(vals) != 2: 1663 | printerr('Invalid range %s.' % nav) 1664 | return 1665 | 1666 | if vals[0] > vals[1]: 1667 | vals[0], vals[1] = vals[1], vals[0] 1668 | 1669 | for _id in range(vals[0], vals[1] + 1): 1670 | if self._opts.num and _id not in range(low, high): 1671 | printerr('Invalid index %s.' % (_id - self.index)) 1672 | continue 1673 | 1674 | if str(_id) in self._urltable: 1675 | open_url(self._urltable[str(_id)]) 1676 | else: 1677 | printerr('Invalid index %s.' % _id) 1678 | except ValueError: 1679 | printerr('Invalid range %s.' % nav) 1680 | 1681 | @require_keywords 1682 | def do_open(self, low, high, *args): 1683 | if not args: 1684 | printerr('Index or range missing.') 1685 | return 1686 | 1687 | for nav in args: 1688 | if nav == 'a': 1689 | for key, _ in sorted(self._urltable.items()): 1690 | if self._opts.num and int(key) not in range(low, high): 1691 | continue 1692 | open_url(self._urltable[key]) 1693 | elif nav in self._urltable: 1694 | if self._opts.num: 1695 | nav = str(int(nav) + self.index) 1696 | if int(nav) not in range(low, high): 1697 | printerr('Invalid index %s.' % (int(nav) - self.index)) 1698 | continue 1699 | open_url(self._urltable[nav]) 1700 | elif '-' in nav: 1701 | self.handle_range(nav, low, high) 1702 | else: 1703 | printerr('Invalid index %s.' % nav) 1704 | 1705 | @require_keywords 1706 | @no_argument 1707 | def do_previous(self): 1708 | if self._opts.num: 1709 | if self.index < self._opts.num: 1710 | print('Already at the first page.', file=sys.stderr) 1711 | else: 1712 | self.index -= self._opts.num 1713 | self.display_results() 1714 | return 1715 | 1716 | try: 1717 | self._ddg_url.prev_page() 1718 | except ValueError as e: 1719 | print(e, file=sys.stderr) 1720 | return 1721 | 1722 | self.fetch_and_display() 1723 | 1724 | def copy_url(self, idx): 1725 | try: 1726 | content = self._urltable[str(idx)].encode('utf-8') 1727 | 1728 | # try copying the url to clipboard using native utilities 1729 | copier_params = [] 1730 | if sys.platform.startswith(('linux', 'freebsd', 'openbsd')): 1731 | if shutil.which('xsel') is not None: 1732 | copier_params = ['xsel', '-b', '-i'] 1733 | elif shutil.which('xclip') is not None: 1734 | copier_params = ['xclip', '-selection', 'clipboard'] 1735 | elif shutil.which('wl-copy') is not None: 1736 | copier_params = ['wl-copy'] 1737 | # If we're using Termux (Android) use its 'termux-api' 1738 | # add-on to set device clipboard. 1739 | elif shutil.which('termux-clipboard-set') is not None: 1740 | copier_params = ['termux-clipboard-set'] 1741 | elif sys.platform == 'darwin': 1742 | copier_params = ['pbcopy'] 1743 | elif sys.platform == 'win32': 1744 | copier_params = ['clip'] 1745 | elif sys.platform.startswith('haiku'): 1746 | copier_params = ['clipboard', '-i'] 1747 | 1748 | if copier_params: 1749 | Popen(copier_params, stdin=PIPE, stdout=DEVNULL, stderr=DEVNULL).communicate(content) 1750 | return 1751 | 1752 | # If native clipboard utilities are absent, try to use terminal multiplexers 1753 | # tmux 1754 | if os.getenv('TMUX_PANE'): 1755 | copier_params = ['tmux', 'set-buffer'] 1756 | Popen(copier_params + [content], stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL).communicate() 1757 | print('URL copied to tmux buffer.') 1758 | return 1759 | 1760 | # GNU Screen paste buffer 1761 | if os.getenv('STY'): 1762 | copier_params = ['screen', '-X', 'readbuf', '-e', 'utf8'] 1763 | tmpfd, tmppath = tempfile.mkstemp() 1764 | try: 1765 | with os.fdopen(tmpfd, 'wb') as fp: 1766 | fp.write(content) 1767 | copier_params.append(tmppath) 1768 | Popen(copier_params, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL).communicate() 1769 | finally: 1770 | os.unlink(tmppath) 1771 | return 1772 | 1773 | printerr('failed to locate suitable clipboard utility') 1774 | except Exception as e: 1775 | raise NoKeywordsException from e 1776 | 1777 | def cmdloop(self): 1778 | """Run REPL.""" 1779 | if self.keywords: 1780 | if self.keywords[0][0] == '!' or ( 1781 | len(self.keywords[0]) > 1 and self.keywords[0][1] == '!' 1782 | ): 1783 | open_url(self._ddg_url.full()) 1784 | else: 1785 | self.fetch_and_display() 1786 | 1787 | while True: 1788 | self.read_next_command() 1789 | # Automatic dispatcher 1790 | # 1791 | # We can't write a dispatcher for now because that could 1792 | # change behaviour of the prompt. However, we have already 1793 | # laid a lot of ground work for the dispatcher, e.g., the 1794 | # `no_argument' decorator. 1795 | 1796 | _num = self._opts.num 1797 | try: 1798 | cmd = self.cmd 1799 | if cmd == 'f': 1800 | self.do_first('') 1801 | elif cmd.startswith('d '): 1802 | self.do_ddg(cmd[2:]) 1803 | elif cmd == 'n': 1804 | self.do_next('') 1805 | elif cmd.startswith('o '): 1806 | self.do_open(self.index + 1, self.index + self._opts.num + 1, *cmd[2:].split()) 1807 | elif cmd.startswith('O '): 1808 | open_url.override_text_browser = True 1809 | self.do_open(self.index + 1, self.index + self._opts.num + 1, *cmd[2:].split()) 1810 | open_url.override_text_browser = False 1811 | elif cmd == 'p': 1812 | self.do_previous('') 1813 | elif cmd == 'q': 1814 | break 1815 | elif cmd == '?': 1816 | self.help() 1817 | elif _num and cmd.isdigit() and int(cmd) in range(1, _num + 1): 1818 | open_url(self._urltable[str(int(cmd) + self.index)]) 1819 | elif _num == 0 and cmd in self._urltable: 1820 | open_url(self._urltable[cmd]) 1821 | elif self.keywords and cmd.isdigit() and int(cmd) < 100: 1822 | printerr('Index out of bound. To search for the number, use d.') 1823 | elif cmd == 'x': 1824 | Result.urlexpand = not Result.urlexpand 1825 | self.display_results() 1826 | elif cmd.startswith('c ') and cmd[2:].isdigit(): 1827 | idx = int(cmd[2:]) 1828 | if 0 < idx <= min(self._opts.num, len(self._urltable)): 1829 | self.copy_url(int(self.index) + idx) 1830 | else: 1831 | printerr("invalid index") 1832 | else: 1833 | self.do_ddg(cmd) 1834 | except KeyError: 1835 | printerr('Index out of bound. To search for the number, use d.') 1836 | except NoKeywordsException: 1837 | printerr('Initiate a query first.') 1838 | 1839 | 1840 | class DdgArgumentParser(argparse.ArgumentParser): 1841 | """Custom argument parser for ddgr.""" 1842 | 1843 | # Print omniprompt help 1844 | @staticmethod 1845 | def print_omniprompt_help(file=None): 1846 | file = sys.stderr if file is None else file 1847 | file.write(textwrap.dedent(""" 1848 | omniprompt keys: 1849 | n, p, f fetch the next, prev or first set of search results 1850 | index open the result corresponding to index in browser 1851 | o [index|range|a ...] open space-separated result indices, ranges or all 1852 | O [index|range|a ...] like key 'o', but try to open in a GUI browser 1853 | d keywords new DDG search for 'keywords' with original options 1854 | should be used to search omniprompt keys and indices 1855 | x toggle url expansion 1856 | c index copy url to clipboard 1857 | q, ^D, double Enter exit ddgr 1858 | ? show omniprompt help 1859 | * other inputs are considered as new search keywords 1860 | """)) 1861 | 1862 | # Print information on ddgr 1863 | @staticmethod 1864 | def print_general_info(file=None): 1865 | file = sys.stderr if file is None else file 1866 | file.write(textwrap.dedent(""" 1867 | Version %s 1868 | Copyright © 2016-2023 Arun Prakash Jana 1869 | License: GPLv3 1870 | Webpage: https://github.com/jarun/ddgr 1871 | """ % _VERSION_)) 1872 | 1873 | # Augment print_help to print more than synopsis and options 1874 | def print_help(self, file=None): 1875 | super().print_help(file) 1876 | self.print_omniprompt_help(file) 1877 | self.print_general_info(file) 1878 | 1879 | # Automatically print full help text on error 1880 | def error(self, message): 1881 | sys.stderr.write('%s: error: %s\n\n' % (self.prog, message)) 1882 | self.print_help(sys.stderr) 1883 | self.exit(2) 1884 | 1885 | # Type guards 1886 | @staticmethod 1887 | def positive_int(arg): 1888 | """Try to convert a string into a positive integer.""" 1889 | try: 1890 | n = int(arg) 1891 | assert n > 0 1892 | return n 1893 | except (ValueError, AssertionError) as e: 1894 | raise argparse.ArgumentTypeError('%s is not a positive integer' % arg) from e 1895 | 1896 | @staticmethod 1897 | def nonnegative_int(arg): 1898 | """Try to convert a string into a nonnegative integer <= 25.""" 1899 | try: 1900 | n = int(arg) 1901 | assert n >= 0 1902 | assert n <= 25 1903 | return n 1904 | except (ValueError, AssertionError) as e: 1905 | raise argparse.ArgumentTypeError('%s is not a non-negative integer <= 25' % arg) from e 1906 | 1907 | @staticmethod 1908 | def is_duration(arg): 1909 | """Check if a string is a valid duration accepted by DuckDuckGo. 1910 | 1911 | A valid duration is of the form dNUM, where d is a single letter h 1912 | (hour), d (day), w (week), m (month), or y (year), and NUM is a 1913 | non-negative integer. 1914 | """ 1915 | try: 1916 | if arg[0] not in ('h', 'd', 'w', 'm', 'y') or int(arg[1:]) < 0: 1917 | raise ValueError 1918 | except (TypeError, IndexError, ValueError) as e: 1919 | raise argparse.ArgumentTypeError('%s is not a valid duration' % arg) from e 1920 | return arg 1921 | 1922 | @staticmethod 1923 | def is_colorstr(arg): 1924 | """Check if a string is a valid color string.""" 1925 | try: 1926 | assert len(arg) == 6 1927 | for c in arg: 1928 | assert c in COLORMAP 1929 | except AssertionError as e: 1930 | raise argparse.ArgumentTypeError('%s is not a valid color string' % arg) from e 1931 | return arg 1932 | 1933 | 1934 | # Miscellaneous functions 1935 | 1936 | def python_version(): 1937 | return '%d.%d.%d' % sys.version_info[:3] 1938 | 1939 | 1940 | def get_colorize(colorize): 1941 | if colorize == 'always': 1942 | return True 1943 | 1944 | if colorize == 'auto': 1945 | return sys.stdout.isatty() 1946 | 1947 | # colorize = 'never' 1948 | return False 1949 | 1950 | 1951 | def set_win_console_mode(): 1952 | # VT100 control sequences are supported on Windows 10 Anniversary Update and later. 1953 | # https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences 1954 | # https://docs.microsoft.com/en-us/windows/console/setconsolemode 1955 | if platform.release() == '10': 1956 | STD_OUTPUT_HANDLE = -11 1957 | STD_ERROR_HANDLE = -12 1958 | ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 1959 | try: 1960 | from ctypes import windll, wintypes, byref 1961 | kernel32 = windll.kernel32 1962 | for nhandle in (STD_OUTPUT_HANDLE, STD_ERROR_HANDLE): 1963 | handle = kernel32.GetStdHandle(nhandle) 1964 | old_mode = wintypes.DWORD() 1965 | if not kernel32.GetConsoleMode(handle, byref(old_mode)): 1966 | raise RuntimeError('GetConsoleMode failed') 1967 | new_mode = bin(old_mode.value) | ENABLE_VIRTUAL_TERMINAL_PROCESSING 1968 | if not kernel32.SetConsoleMode(handle, new_mode): 1969 | raise RuntimeError('SetConsoleMode failed') 1970 | # Note: No need to restore at exit. SetConsoleMode seems to 1971 | # be limited to the calling process. 1972 | except Exception: 1973 | pass 1974 | 1975 | 1976 | # Query autocompleter 1977 | 1978 | # This function is largely experimental and could raise any exception; 1979 | # you should be prepared to catch anything. When it works though, it 1980 | # returns a list of strings the prefix could autocomplete to (however, 1981 | # it is not guaranteed that they start with the specified prefix; for 1982 | # instance, they won't if the specified prefix ends in a punctuation 1983 | # mark.) 1984 | def completer_fetch_completions(prefix): 1985 | # One can pass the 'hl' query param to specify the language. We 1986 | # ignore that for now. 1987 | api_url = ('https://duckduckgo.com/ac/?q=%s&kl=wt-wt' % 1988 | urllib.parse.quote(prefix, safe='')) 1989 | # A timeout of 3 seconds seems to be overly generous already. 1990 | with urllib.request.urlopen(api_url, timeout=3) as resp: 1991 | with json.loads(resp.read().decode('utf-8')) as respobj: 1992 | return [entry['phrase'] for entry in respobj] 1993 | 1994 | 1995 | def completer_run(prefix): 1996 | if prefix: 1997 | completions = completer_fetch_completions('+'.join(prefix.split())) 1998 | if completions: 1999 | print('\n'.join(completions)) 2000 | sys.exit(0) 2001 | 2002 | 2003 | def parse_args(args=None, namespace=None): 2004 | """Parse ddgr arguments/options. 2005 | 2006 | Parameters 2007 | ---------- 2008 | args : list, optional 2009 | Arguments to parse. Default is ``sys.argv``. 2010 | namespace : argparse.Namespace 2011 | Namespace to write to. Default is a new namespace. 2012 | 2013 | Returns 2014 | ------- 2015 | argparse.Namespace 2016 | Namespace with parsed arguments / options. 2017 | 2018 | """ 2019 | 2020 | colorstr_env = os.getenv('DDGR_COLORS') 2021 | 2022 | argparser = DdgArgumentParser(description='DuckDuckGo from the terminal.') 2023 | addarg = argparser.add_argument 2024 | addarg('-n', '--num', type=argparser.nonnegative_int, default=10, metavar='N', 2025 | help='show N (0<=N<=25) results per page (default 10); N=0 shows actual number of results fetched per page') 2026 | addarg('-r', '--reg', dest='region', default='us-en', metavar='REG', 2027 | help="region-specific search e.g. 'us-en' for US (default); visit https://duckduckgo.com/params") 2028 | addarg('--colorize', nargs='?', choices=['auto', 'always', 'never'], 2029 | const='always', default='auto', 2030 | help="""whether to colorize output; defaults to 'auto', which enables 2031 | color when stdout is a tty device; using --colorize without an argument 2032 | is equivalent to --colorize=always""") 2033 | addarg('-C', '--nocolor', action='store_true', help='equivalent to --colorize=never') 2034 | addarg('--colors', dest='colorstr', type=argparser.is_colorstr, default=colorstr_env if colorstr_env else 'oCdgxy', metavar='COLORS', 2035 | help='set output colors (see man page for details)') 2036 | addarg('-j', '--ducky', action='store_true', help='open the first result in a web browser; implies --np') 2037 | addarg('-t', '--time', dest='duration', metavar='SPAN', default='', choices=('d', 'w', 'm', 'y'), help='time limit search ' 2038 | '[d (1 day), w (1 wk), m (1 month), y (1 year)]') 2039 | addarg('-w', '--site', dest='sites', action='append', metavar='SITE', help='search sites using DuckDuckGo') 2040 | addarg('-x', '--expand', action='store_true', help='Show complete url in search results') 2041 | addarg('-p', '--proxy', metavar='URI', help='tunnel traffic through an HTTPS proxy; URI format: [http[s]://][user:pwd@]host[:port]') 2042 | addarg('--unsafe', action='store_true', help='disable safe search') 2043 | addarg('--noua', action='store_true', help='disable user agent') 2044 | addarg('--json', action='store_true', help='output in JSON format; implies --np') 2045 | addarg('--gb', '--gui-browser', dest='gui_browser', action='store_true', help='open a bang directly in gui browser') 2046 | addarg('--np', '--noprompt', dest='noninteractive', action='store_true', help='perform search and exit, do not prompt') 2047 | addarg('--rev', '--reverse', dest='reverse', action='store_true', help='list entries in reversed order') 2048 | addarg('--url-handler', metavar='UTIL', help='custom script or cli utility to open results') 2049 | addarg('--show-browser-logs', action='store_true', help='do not suppress browser output (stdout and stderr)') 2050 | addarg('-v', '--version', action='version', version=_VERSION_) 2051 | addarg('-d', '--debug', action='store_true', help='enable debugging') 2052 | addarg('keywords', nargs='*', metavar='KEYWORD', help='search keywords') 2053 | addarg('--complete', help=argparse.SUPPRESS) 2054 | 2055 | parsed = argparser.parse_args(args, namespace) 2056 | if parsed.nocolor: 2057 | parsed.colorize = 'never' 2058 | 2059 | return parsed 2060 | 2061 | 2062 | def main(): 2063 | opts = parse_args() 2064 | 2065 | # Set logging level 2066 | if opts.debug: 2067 | LOGGER.setLevel(logging.DEBUG) 2068 | LOGDBG('ddgr version %s Python version %s', _VERSION_, python_version()) 2069 | 2070 | # Handle query completer 2071 | if opts.complete is not None: 2072 | completer_run(opts.complete) 2073 | 2074 | check_stdout_encoding() 2075 | 2076 | # Add cmdline args to readline history 2077 | if opts.keywords: 2078 | try: 2079 | readline.add_history(' '.join(opts.keywords)) 2080 | except Exception: 2081 | pass 2082 | 2083 | # Set colors 2084 | colorize = get_colorize(opts.colorize) 2085 | 2086 | colors = Colors(*[COLORMAP[c] for c in opts.colorstr], reset=COLORMAP['x']) if colorize else None 2087 | Result.colors = colors 2088 | Result.urlexpand = opts.expand 2089 | DdgCmd.colors = colors 2090 | 2091 | # Try to enable ANSI color support in cmd or PowerShell on Windows 10 2092 | if sys.platform == 'win32' and sys.stdout.isatty() and colorize: 2093 | set_win_console_mode() 2094 | 2095 | if opts.url_handler is not None: 2096 | open_url.url_handler = opts.url_handler 2097 | else: 2098 | open_url.override_text_browser = bool(opts.gui_browser) 2099 | 2100 | # Handle browser output suppression 2101 | open_url.suppress_browser_output = not (opts.show_browser_logs or (os.getenv('BROWSER') in TEXT_BROWSERS)) 2102 | 2103 | try: 2104 | repl = DdgCmd(opts, '' if opts.noua else USER_AGENT) 2105 | 2106 | if opts.json or opts.ducky or opts.noninteractive: 2107 | # Non-interactive mode 2108 | if repl.keywords and ( 2109 | repl.keywords[0][0] == '!' or 2110 | (len(repl.keywords[0]) > 1 and repl.keywords[0][1] == '!') 2111 | ): 2112 | # Handle bangs 2113 | open_url(repl._ddg_url.full()) 2114 | else: 2115 | repl.fetch() 2116 | if opts.ducky: 2117 | if repl.results: 2118 | open_url(repl.results[0].url) 2119 | else: 2120 | print('No results.', file=sys.stderr) 2121 | else: 2122 | if not opts.reverse: 2123 | repl.display_instant_answer(json_output=opts.json) 2124 | repl.display_results(prelude='', json_output=opts.json) 2125 | else: 2126 | repl.display_results(prelude='', json_output=opts.json) 2127 | repl.display_instant_answer(json_output=opts.json) 2128 | 2129 | sys.exit(0) 2130 | 2131 | # Interactive mode 2132 | repl.cmdloop() 2133 | except Exception as e: 2134 | # If debugging mode is enabled, let the exception through for a traceback; 2135 | # otherwise, only print the exception error message. 2136 | if LOGGER.isEnabledFor(logging.DEBUG): 2137 | raise 2138 | 2139 | LOGERR(e) 2140 | sys.exit(1) 2141 | 2142 | 2143 | if __name__ == '__main__': 2144 | main() 2145 | --------------------------------------------------------------------------------