Compare commits
18
Commits
9848120a1a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8070edb8 | ||
|
|
2a50b26f4a | ||
|
|
2717910bc7 | ||
|
|
2323a2d1ab | ||
|
|
aa70c4f6f6 | ||
|
|
197a54e41d | ||
|
|
752bb94d48 | ||
|
|
ff10b642a7 | ||
|
|
def4568355 | ||
|
|
5697cd0c40 | ||
|
|
723ca1506b | ||
|
|
9eed02645b | ||
|
|
ac41cc5771 | ||
|
|
4f9d83a451 | ||
|
|
854348ebeb | ||
|
|
d7f252034e | ||
|
|
79a73f8f2e | ||
|
|
fd136db535 |
@@ -5,10 +5,10 @@ TELEGRAM_CHAT_ID=7889499475
|
||||
|
||||
|
||||
# AI Provider (openrouter | stepfun | qwencloud | llamacpp)
|
||||
AI_PROVIDER=openrouter
|
||||
AI_PROVIDER=stepfun
|
||||
|
||||
# Default AI Model
|
||||
DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
||||
DEFAULT_MODEL=step-3.7-flash
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ MAX_HISTORY_LENGTH=50
|
||||
WEB_SEARCH_ENABLED=true
|
||||
WEB_SEARCH_MAX_RESULTS=5
|
||||
|
||||
# Scraping configuration
|
||||
WEB_SEARCH_SCRAPE_ENABLED=true
|
||||
WEB_SEARCH_SCRAPE_MAX_CHARS=2500
|
||||
WEB_SEARCH_SCRAPE_CACHE_TTL=3600
|
||||
SCRAPING_BLOCKED_DOMAINS=
|
||||
SCRAPING_BLOCKED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.svg,.zip,.rar,.exe,.dmg,.bin
|
||||
|
||||
# Vision / image analysis model
|
||||
VISION_MODEL=meta-llama/llama-4-maverick-17b-128e-instruct
|
||||
VISION_PROVIDER=openrouter
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# =============================================================================
|
||||
# PsAgentAI - Environment Configuration Example
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your actual values:
|
||||
# cp .env.example .env
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Telegram Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your bot token from @BotFather (https://t.me/BotFather)
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Your Telegram chat ID (the bot will send proactive messages to this chat)
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Provider Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Provider: openrouter, stepfun, qwencloud or llamacpp
|
||||
AI_PROVIDER=openrouter
|
||||
|
||||
# Default model identifier (depends on the chosen provider)
|
||||
DEFAULT_MODEL=poolside/laguna-s-2.1:free
|
||||
|
||||
# Comma-separated fallback models if the primary fails
|
||||
FALLBACK_MODELS=poolside/laguna-s-2.1:free,google/gemini-2.0-flash-lite-preview-02-05:free
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OpenRouter Configuration (https://openrouter.ai)
|
||||
# -----------------------------------------------------------------------------
|
||||
OPENROUTER_API_KEY=your_openrouter_key_here
|
||||
OPENROUTER_API_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# StepFun Configuration (https://platform.stepfun.com)
|
||||
# -----------------------------------------------------------------------------
|
||||
STEPFUN_API_KEY=your_stepfun_key_here
|
||||
STEPFUN_API_URL=https://api.stepfun.com/v1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# QwenCloud Configuration (https://dashscope.aliyun.com)
|
||||
# -----------------------------------------------------------------------------
|
||||
QWENCLOUD_API_KEY=your_qwencloud_key_here
|
||||
QWENCLOUD_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# llama.cpp Configuration (local server)
|
||||
# -----------------------------------------------------------------------------
|
||||
# URL of your local llama.cpp server (OpenAI-compatible endpoint)
|
||||
LLAMA_CPP_API_URL=http://localhost:8080/v1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Proactive Messaging
|
||||
# -----------------------------------------------------------------------------
|
||||
# Minimum and maximum interval between proactive messages (minutes)
|
||||
MIN_INTERVAL_MINUTES=3
|
||||
MAX_INTERVAL_MINUTES=30
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Context and History
|
||||
# -----------------------------------------------------------------------------
|
||||
# Maximum number of messages kept in conversation context
|
||||
MAX_HISTORY_LENGTH=50
|
||||
|
||||
# Maximum characters from uploaded files sent to AI (0 = no truncation)
|
||||
UPLOAD_CONTEXT_MAX_CHARS=8000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Search
|
||||
# -----------------------------------------------------------------------------
|
||||
# Enable automatic web search when the agent detects an information need
|
||||
WEB_SEARCH_ENABLED=true
|
||||
|
||||
# Maximum number of search results to include in context
|
||||
WEB_SEARCH_MAX_RESULTS=5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vision / Image Analysis
|
||||
# -----------------------------------------------------------------------------
|
||||
# Model used for image analysis
|
||||
VISION_MODEL=meta-llama/llama-4-maverick-17b-128b-instruct
|
||||
|
||||
# Provider for the vision model (leave empty to auto-detect from model)
|
||||
VISION_PROVIDER=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agent Behavior
|
||||
# -----------------------------------------------------------------------------
|
||||
# Minimum number of lines per agent message
|
||||
MIN_MESSAGE_LINES=3
|
||||
|
||||
# Auto-enrich user/agent profiles every N messages (0 = disabled)
|
||||
AUTO_ENRICH_EVERY=2
|
||||
|
||||
# Auto-improve system every N messages (0 = disabled)
|
||||
AUTO_IMPROVE_ENABLED=true
|
||||
AUTO_IMPROVE_EVERY=20
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# -----------------------------------------------------------------------------
|
||||
# File paths for profiles and contexts (relative to app directory)
|
||||
USER_PROFILE_FILE=user_profile.json
|
||||
AGENT_PROFILE_FILE=agent_profile.json
|
||||
CONTEXTS_DIR=contexts
|
||||
@@ -1,17 +1,38 @@
|
||||
# PsAgentAI - Server Deployment
|
||||
|
||||
Deploy as a systemd service on Debian/Ubuntu to run 24/7 on a VPS.
|
||||
Deploy PsAgentAI as a systemd service on Debian/Ubuntu to run 24/7 on a VPS.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Copy Project to Server](#1-copy-project-to-server)
|
||||
3. [Configure Environment](#2-configure-environment)
|
||||
4. [Install Dependencies](#3-install-dependencies)
|
||||
5. [Install systemd Service](#4-install-systemd-service)
|
||||
6. [Service Management](#5-service-management)
|
||||
7. [Updates](#6-updates)
|
||||
8. [Multi-User Deployment](#7-multi-user-deployment)
|
||||
9. [Environment Variables Reference](#8-environment-variables-reference)
|
||||
10. [Backups](#9-backups)
|
||||
11. [Security](#10-security)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites on the server
|
||||
## Prerequisites
|
||||
|
||||
A fresh Debian/Ubuntu server with:
|
||||
|
||||
- `python3`, `python3-venv`, `python3-pip`
|
||||
- `git` (if cloning from repository)
|
||||
- `ffmpeg` (for voice message processing)
|
||||
- `systemd-journal-remote` (optional, for persistent logs)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip git ffmpeg
|
||||
```
|
||||
|
||||
Enable journal if you want persistent logs (optional):
|
||||
Optional: enable persistent journal logs:
|
||||
|
||||
```bash
|
||||
sudo apt install -y systemd-journal-remote
|
||||
@@ -19,9 +40,9 @@ sudo apt install -y systemd-journal-remote
|
||||
|
||||
---
|
||||
|
||||
## 1) Copy the project to the server
|
||||
## 1) Copy Project to Server
|
||||
|
||||
Use git or scp/rsync. Example with git:
|
||||
### Using git
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
@@ -30,77 +51,95 @@ sudo chown -R $USER:$USER /opt/psagentai
|
||||
cd /opt/psagentai/app
|
||||
```
|
||||
|
||||
Adjust the path in the systemd service to the real directory:
|
||||
### Using scp/rsync
|
||||
|
||||
```bash
|
||||
nano psagentai.service
|
||||
# change /home/marco/PsAgentAI/app to /opt/psagentai/app
|
||||
# From your local machine:
|
||||
scp -r psagentai/ user@server:/opt/psagentai/
|
||||
```
|
||||
|
||||
> The service uses `User=marco` by default. Create the user if it doesn't exist:
|
||||
> ```bash
|
||||
> sudo useradd -m -s /bin/bash marco || true
|
||||
> sudo usermod -aG sudo marco
|
||||
> ```
|
||||
> Or edit `User=` in the `.service` to an existing user on your VPS.
|
||||
Adjust paths in the systemd service file to match your actual installation directory.
|
||||
|
||||
---
|
||||
|
||||
## 2) Configure `.env`
|
||||
## 2) Configure Environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env # if example exists
|
||||
# If .env.example exists, copy it:
|
||||
cp .env.example .env
|
||||
|
||||
# Otherwise create .env manually:
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fill in the required fields:
|
||||
### Required settings
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `TELEGRAM_CHAT_ID`
|
||||
- `AI_PROVIDER` (`openrouter`, `stepfun`, `qwencloud` or `llamacpp`)
|
||||
- `DEFAULT_MODEL`
|
||||
- at least the AI key corresponding to the chosen provider
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `TELEGRAM_BOT_TOKEN` | Token from @BotFather |
|
||||
| `TELEGRAM_CHAT_ID` | Your Telegram chat ID |
|
||||
| `AI_PROVIDER` | `openrouter`, `stepfun`, `qwencloud` or `llamacpp` |
|
||||
| `DEFAULT_MODEL` | Model identifier for the chosen provider |
|
||||
| `OPENROUTER_API_KEY` or `STEPFUN_API_KEY` or `QWENCLOUD_API_KEY` | At least one AI provider key |
|
||||
|
||||
Optionally adjust:
|
||||
### Optional settings
|
||||
|
||||
- `MIN_INTERVAL_MINUTES` / `MAX_INTERVAL_MINUTES`
|
||||
- `MIN_MESSAGE_LINES`
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `MIN_INTERVAL_MINUTES` | `3` | Minimum minutes between proactive messages |
|
||||
| `MAX_INTERVAL_MINUTES` | `30` | Maximum minutes between proactive messages |
|
||||
| `MIN_MESSAGE_LINES` | `3` | Minimum lines per agent message |
|
||||
| `WEB_SEARCH_ENABLED` | `true` | Enable automatic web search |
|
||||
| `WEB_SEARCH_MAX_RESULTS` | `5` | Max web search results |
|
||||
| `WEB_SEARCH_SCRAPE_ENABLED` | `true` | Fetch page content for search results (uses headless browser fallback) |
|
||||
| `WEB_SEARCH_SCRAPE_CACHE_TTL` | `3600` | Cache TTL for scraped pages in seconds |
|
||||
| `WEB_SEARCH_SCRAPE_MAX_CHARS` | `15000` | Max characters per scraped page |
|
||||
| `VISION_MODEL` | `meta-llama/llama-4-maverick-17b-128b-instruct` | Vision model |
|
||||
| `VISION_PROVIDER` | auto | Vision provider |
|
||||
| `AUTO_ENRICH_EVERY` | `2` | Profile auto-enrichment frequency (`0` = disabled) |
|
||||
| `AUTO_IMPROVE_EVERY` | `20` | Auto-improvement frequency (`0` = disabled) |
|
||||
| `UPLOAD_CONTEXT_MAX_CHARS` | `8000` | Upload context limit (`0` = unlimited) |
|
||||
|
||||
> Important: on Telegram, send `/start` once to the bot before turning on the service, otherwise it won't be able to send starting messages.
|
||||
> **Important**: Before starting the service, send `/start` once to the bot in Telegram. This allows the bot to resolve the chat and send proactive messages later.
|
||||
|
||||
---
|
||||
|
||||
## 3) Install dependencies
|
||||
## 3) Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install system dependency for voice processing
|
||||
sudo apt install -y ffmpeg
|
||||
|
||||
```bash
|
||||
# Make setup script executable and run it
|
||||
chmod +x iniciar.sh
|
||||
./iniciar.sh
|
||||
```
|
||||
|
||||
This script creates the `venv`, installs dependencies and validates the `.env` variables.
|
||||
The setup script will:
|
||||
1. Create a Python virtual environment (`venv/`)
|
||||
2. Upgrade `pip`
|
||||
3. Install Python dependencies from `requirements.txt`
|
||||
4. Validate `.env` configuration
|
||||
5. Start the agent
|
||||
|
||||
If the above command doesn't install the expected `ffmpeg` version, also use:
|
||||
If `ffmpeg` was not installed by the system package manager, install it separately:
|
||||
|
||||
```bash
|
||||
sudo apt install -y ffmpeg
|
||||
```
|
||||
|
||||
> Voice recognition uses `SpeechRecognition` + `pydub` + `ffmpeg`.
|
||||
> Voice recognition requires `SpeechRecognition` + `pydub` + `ffmpeg`.
|
||||
|
||||
---
|
||||
|
||||
## 4) Install the systemd service
|
||||
## 4) Install systemd Service
|
||||
|
||||
Create the service file:
|
||||
|
||||
```bash
|
||||
# absolute path on your server
|
||||
sudo nano /etc/systemd/system/psagentai.service
|
||||
```
|
||||
|
||||
Minimum content:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=PsAgentAI
|
||||
@@ -109,6 +148,7 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=marco
|
||||
Group=marco
|
||||
WorkingDirectory=/opt/psagentai/app
|
||||
Environment="PATH=/opt/psagentai/app/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=/opt/psagentai/app/venv/bin/python3 /opt/psagentai/app/psagentai_agent.py
|
||||
@@ -121,7 +161,21 @@ StandardError=journal
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
Adjust the following to match your setup:
|
||||
- `User=` and `Group=` — the user that will run the service
|
||||
- `WorkingDirectory=` — absolute path to the `app/` directory
|
||||
- `ExecStart=` — absolute path to the Python executable and agent script
|
||||
|
||||
Create the service user if needed:
|
||||
|
||||
```bash
|
||||
sudo useradd -m -s /bin/bash marco || true
|
||||
sudo usermod -aG sudo marco
|
||||
```
|
||||
|
||||
Or change `User=` and `Group=` to an existing user on your VPS.
|
||||
|
||||
Reload systemd and enable the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
@@ -131,25 +185,28 @@ sudo systemctl start psagentai
|
||||
|
||||
---
|
||||
|
||||
## 5) Useful commands
|
||||
## 5) Service Management
|
||||
|
||||
```bash
|
||||
# status
|
||||
# Check status
|
||||
sudo systemctl status psagentai
|
||||
|
||||
# real-time logs
|
||||
# View real-time logs
|
||||
sudo journalctl -u psagentai -f
|
||||
|
||||
# restart after update
|
||||
# Stop the service
|
||||
sudo systemctl stop psagentai
|
||||
|
||||
# Restart after changes
|
||||
sudo systemctl restart psagentai
|
||||
|
||||
# stop
|
||||
sudo systemctl stop psagentai
|
||||
# Disable auto-start
|
||||
sudo systemctl disable psagentai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Future updates
|
||||
## 6) Updates
|
||||
|
||||
```bash
|
||||
cd /opt/psagentai/app
|
||||
@@ -160,80 +217,192 @@ sudo systemctl restart psagentai
|
||||
|
||||
---
|
||||
|
||||
## 7) Multiple users / VPSs
|
||||
## 7) Multi-User Deployment
|
||||
|
||||
Each system instance is independent by Telegram chat ID.
|
||||
Each PsAgentAI instance is independent by Telegram chat ID. To run multiple instances:
|
||||
|
||||
For each new user/VPS:
|
||||
1. Create a separate bot via [@BotFather](https://t.me/BotFather) (`/newbot`) for each user
|
||||
2. Copy the project to a separate directory for each instance, e.g.:
|
||||
- `/opt/psagentai-user1/`
|
||||
- `/opt/psagentai-user2/`
|
||||
3. Create a dedicated `.env` for each instance with its own `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, and AI keys
|
||||
4. Run `./iniciar.sh` in each directory to set up the environment
|
||||
5. Create a separate systemd service file for each instance:
|
||||
|
||||
1. Create the bot via @BotFather (`/newbot`) and get the token.
|
||||
2. Copy the project to a separate directory, for example `/opt/psagentai-user2`.
|
||||
3. Create your own `.env` with `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` and your AI keys.
|
||||
4. Install dependencies with `./iniciar.sh`.
|
||||
5. Create a separate systemd service, ex: `psagentai-user2.service`, pointing to that directory.
|
||||
6. Enable and start.
|
||||
```bash
|
||||
# Example for user2
|
||||
sudo nano /etc/systemd/system/psagentai-user2.service
|
||||
```
|
||||
|
||||
Recommended names to avoid conflicts:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=PsAgentAI - User 2
|
||||
After=network.target
|
||||
|
||||
- `/opt/psagentai-user1/`, `/opt/psagentai-user2/`
|
||||
- `psagentai-user1.service`, `psagentai-user2.service`
|
||||
[Service]
|
||||
Type=simple
|
||||
User=marco
|
||||
Group=marco
|
||||
WorkingDirectory=/opt/psagentai-user2/app
|
||||
Environment="PATH=/opt/psagentai-user2/app/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=/opt/psagentai-user2/app/venv/bin/python3 /opt/psagentai-user2/app/psagentai_agent.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
6. Enable and start each service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable psagentai-user2
|
||||
sudo systemctl start psagentai-user2
|
||||
```
|
||||
|
||||
Recommended naming convention to avoid conflicts:
|
||||
|
||||
- Directories: `/opt/psagentai-user1/`, `/opt/psagentai-user2/`
|
||||
- Services: `psagentai-user1.service`, `psagentai-user2.service`
|
||||
|
||||
---
|
||||
|
||||
## 8) `.env` variables
|
||||
## 8) Environment Variables Reference
|
||||
|
||||
### Telegram
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `AI_PROVIDER` | no | Preferred provider: `openrouter`, `stepfun`, `qwencloud` or `llamacpp` |
|
||||
| `TELEGRAM_BOT_TOKEN` | yes | Telegram bot token |
|
||||
| `TELEGRAM_CHAT_ID` | yes | User chat ID |
|
||||
| `DEFAULT_MODEL` | yes | Default AI model |
|
||||
| `OPENROUTER_API_KEY` | 1 of the keys | OpenRouter key |
|
||||
| `STEPFUN_API_KEY` | 1 of the keys | StepFun key |
|
||||
| `QWENCLOUD_API_KEY` | 1 of the keys | QwenCloud key |
|
||||
| `MIN_INTERVAL_MINUTES` | no | Minimum interval for proactive messages |
|
||||
| `MAX_INTERVAL_MINUTES` | no | Maximum interval for proactive messages |
|
||||
| `MIN_MESSAGE_LINES` | no | Minimum lines per agent message |
|
||||
| `AUTO_ENRICH_EVERY` | no | Enriches profile every N messages |
|
||||
| `AUTO_IMPROVE_EVERY` | no | Auto improvement every N messages |
|
||||
| `UPLOAD_CONTEXT_MAX_CHARS` | no | Character limit of upload sent to AI (`0` = no limit) |
|
||||
| `VISION_MODEL` | no | AI model for image analysis |
|
||||
| `VISION_PROVIDER` | no | Vision model provider (`openrouter`, `stepfun`, `qwencloud`) |
|
||||
| `TELEGRAM_BOT_TOKEN` | Yes | Telegram bot token from @BotFather |
|
||||
| `TELEGRAM_CHAT_ID` | Yes | Target chat ID for proactive messages |
|
||||
|
||||
### AI Providers
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `AI_PROVIDER` | Yes | Preferred provider: `openrouter`, `stepfun`, `qwencloud` or `llamacpp` |
|
||||
| `DEFAULT_MODEL` | Yes | Model identifier (provider-specific) |
|
||||
| `OPENROUTER_API_KEY` | Conditional | Required for OpenRouter |
|
||||
| `OPENROUTER_API_URL` | No | OpenRouter API base URL |
|
||||
| `STEPFUN_API_KEY` | Conditional | Required for StepFun |
|
||||
| `STEPFUN_API_URL` | No | StepFun API base URL |
|
||||
| `QWENCLOUD_API_KEY` | Conditional | Required for QwenCloud |
|
||||
| `QWENCLOUD_API_URL` | No | QwenCloud API base URL |
|
||||
| `LLAMA_CPP_API_URL` | No | llama.cpp server URL (default: `http://localhost:8080/v1`) |
|
||||
| `FALLBACK_MODELS` | No | Comma-separated fallback models |
|
||||
|
||||
### Behavior Tuning
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `MIN_INTERVAL_MINUTES` | `3` | Minimum minutes between proactive messages |
|
||||
| `MAX_INTERVAL_MINUTES` | `30` | Maximum minutes between proactive messages |
|
||||
| `MIN_MESSAGE_LINES` | `3` | Minimum lines per agent message |
|
||||
| `MAX_HISTORY_LENGTH` | `50` | Max messages in conversation context |
|
||||
| `UPLOAD_CONTEXT_MAX_CHARS` | `8000` | Max chars from uploads sent to AI (`0` = unlimited) |
|
||||
| `WEB_SEARCH_ENABLED` | `true` | Enable automatic web search |
|
||||
| `WEB_SEARCH_MAX_RESULTS` | `5` | Maximum search results returned |
|
||||
| `WEB_SEARCH_SCRAPE_ENABLED` | `true` | Fetch page content for search results (uses headless browser fallback for JS-rendered sites) |
|
||||
| `WEB_SEARCH_SCRAPE_CACHE_TTL` | `3600` | Cache TTL for scraped pages in seconds |
|
||||
| `WEB_SEARCH_SCRAPE_MAX_CHARS` | `15000` | Max characters per scraped page |
|
||||
| `AUTO_ENRICH_EVERY` | `2` | Auto-enrich profiles every N messages (`0` = disabled) |
|
||||
| `AUTO_IMPROVE_EVERY` | `20` | Auto-improve system every N messages (`0` = disabled) |
|
||||
|
||||
### Vision / Image Analysis
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `VISION_MODEL` | `meta-llama/llama-4-maverick-17b-128b-instruct` | Model for image analysis |
|
||||
| `VISION_PROVIDER` | auto | Provider for vision model |
|
||||
|
||||
### Persistence
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `USER_PROFILE_FILE` | `user_profile.json` | User profile file path |
|
||||
| `AGENT_PROFILE_FILE` | `agent_profile.json` | Agent profile file path |
|
||||
| `CONTEXTS_DIR` | `contexts` | Conversation context directory |
|
||||
|
||||
---
|
||||
|
||||
## Useful commands via Telegram
|
||||
## 9) Backups
|
||||
|
||||
- `/viewai` — shows current AI provider and model
|
||||
- `/configai` — interactively configures AI provider and model
|
||||
- `/viewvision` — shows current vision provider and model
|
||||
- `/configvision` — interactively configures vision model
|
||||
- `/about` — displays system information, version, author and license
|
||||
Regularly back up the data files in the `app/` directory:
|
||||
|
||||
---
|
||||
- `user_profile.json` — user profile data
|
||||
- `agent_profile.json` — agent personality and traits
|
||||
- `system_improvements.json` — auto-improvement history
|
||||
- `uploads_metadata.json` — uploaded files index
|
||||
- `config.json` — schedule configuration
|
||||
- `contexts/` — conversation history by date
|
||||
- `workspace/` — created and edited files
|
||||
|
||||
## 9) Important backups
|
||||
|
||||
Regularly backup the data files inside `/app`:
|
||||
|
||||
- `user_profile.json`
|
||||
- `agent_profile.json`
|
||||
- `system_improvements.json`
|
||||
- `uploads_metadata.json`
|
||||
- `contexts/` folder
|
||||
- `workspace/` folder
|
||||
|
||||
Simple daily backup example:
|
||||
### Daily backup example
|
||||
|
||||
```bash
|
||||
tar czf /root/backups/psagentai-$(date +%F).tar.gz -C /opt/psagentai/app user_profile.json agent_profile.json system_improvements.json uploads_metadata.json contexts workspace
|
||||
# Create backups directory
|
||||
sudo mkdir -p /root/backups
|
||||
|
||||
# Run daily backup
|
||||
BACKUP_FILE="/root/backups/psagentai-$(date +%F).tar.gz"
|
||||
tar czf "$BACKUP_FILE" -C /opt/psagentai/app \
|
||||
user_profile.json \
|
||||
agent_profile.json \
|
||||
system_improvements.json \
|
||||
uploads_metadata.json \
|
||||
config.json \
|
||||
contexts \
|
||||
workspace
|
||||
|
||||
echo "Backup created: $BACKUP_FILE"
|
||||
```
|
||||
|
||||
### Automated backup with cron
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```cron
|
||||
0 2 * * * /usr/bin/tar czf /root/backups/psagentai-$(date +\%F).tar.gz -C /opt/psagentai/app user_profile.json agent_profile.json system_improvements.json uploads_metadata.json config.json contexts workspace
|
||||
```
|
||||
|
||||
This creates a backup every day at 2:00 AM.
|
||||
|
||||
---
|
||||
|
||||
## 10) Security
|
||||
|
||||
- Never commit `.env` to the repository.
|
||||
- Restrict permissions: `chmod 600 .env`.
|
||||
- Use an exclusive user for each service instance.
|
||||
- For greater isolation, run each instance in a separate Docker container or on distinct VMs.
|
||||
- **Never commit `.env`** to version control. Add it to `.gitignore`.
|
||||
- **Restrict permissions**: `chmod 600 .env` to prevent other users from reading secrets.
|
||||
- **Use dedicated users**: Run each instance under its own system user for isolation.
|
||||
- **Firewall**: Only expose necessary ports (typically only outbound HTTPS is needed).
|
||||
- **API keys**: Rotate keys periodically and monitor usage for anomalies.
|
||||
- **Path validation**: The agent validates file paths to prevent directory traversal attacks.
|
||||
- **Sensitive data**: Emails and phone numbers in profiles are preserved but not exposed in logs.
|
||||
|
||||
---
|
||||
|
||||
## Useful Telegram Commands for Administration
|
||||
|
||||
- `/viewai` — Check current AI provider and model
|
||||
- `/configai` — Change AI provider/model on the fly
|
||||
- `/viewvision` — Check vision configuration
|
||||
- `/configvision` — Change vision model on the fly
|
||||
- `/debug` — View system debug information
|
||||
- `/reload` — Reload .env without restarting the service
|
||||
- `/timezone` — Check server timezone
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- System developed by: [Syspanel.com.br](https://syspanel.com.br)
|
||||
- Author: Marco A. S. Costa
|
||||
- Email: contato@syspanel.com.br
|
||||
- License: Proprietary for syspanel.com.br
|
||||
|
||||
@@ -1,43 +1,92 @@
|
||||
# PsAgentAI - Personal Conversational AI Agent
|
||||
|
||||
Personal Conversational AI Agent system via Telegram that behaves as a personal companion, chatting, cooperating and maintaining continuous context with the user.
|
||||
A Telegram-based conversational AI agent that acts as a personal companion. It maintains long-term memory through user/agent profiles, manages daily routines, supports file uploads/workspace editing, performs web searches with headless browser fallback, analyzes images, visits websites directly, and sends proactive messages based on configured schedules.
|
||||
|
||||
## Structure
|
||||
## Key Features
|
||||
|
||||
- **Long-term memory**: User and agent profiles with auto-enrichment from conversations
|
||||
- **Daily routine management**: Schedule activities, meals, work/study times
|
||||
- **File workspace**: Create, edit, list and download files via natural language
|
||||
- **Web search**: Automatic or on-demand search with headless browser fallback for JS-rendered e-commerce sites
|
||||
- **Direct website access**: Visit and analyze any website by name — the agent can enter sites and extract their content
|
||||
- **Anti-hallucination**: Post-search validation ensures responses cite sources and don't invent facts
|
||||
- **Vision**: Image analysis with configurable vision models
|
||||
- **Voice**: Voice message transcription and response
|
||||
- **Proactive messaging**: Scheduled automatic messages based on daily routine
|
||||
- **Multi-provider AI**: OpenRouter, StepFun, QwenCloud, and llama.cpp (local)
|
||||
- **System auto-improvement**: AI-driven suggestions to evolve the agent behavior
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── psagentai_agent.py # Main agent code
|
||||
├── iniciar.sh # Installation and execution script
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env # Local settings (do not commit)
|
||||
├── .env.example # Configuration example
|
||||
├── user_profile.json # User profile
|
||||
├── agent_profile.json # Agent profile (personality, traits, notes)
|
||||
├── contexts/ # Conversation history by date
|
||||
├── psagentai_agent.py # Main agent code (~5000 lines)
|
||||
├── iniciar.sh # Setup and run script
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env # Local settings (do not commit)
|
||||
├── user_profile.json # User profile (auto-created)
|
||||
├── agent_profile.json # Agent profile (auto-created)
|
||||
├── uploads_metadata.json # Uploaded files metadata (auto-created)
|
||||
├── config.json # Schedule configuration (auto-created)
|
||||
├── system_improvements.json # Auto-improvement history (auto-created)
|
||||
├── contexts/ # Conversation history by date
|
||||
│ └── YYYY-MM-DD.json
|
||||
├── workspace/ # Created and edited files
|
||||
│ ├── projects/
|
||||
│ └── web_cache/ # Cached webpage content (auto-created)
|
||||
├── README.md
|
||||
├── DEPLOY.md
|
||||
├── RULES.md
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Telegram account with bot created via @BotFather
|
||||
- At least one supported AI API key
|
||||
- Telegram account with a bot created via [@BotFather](https://t.me/BotFather)
|
||||
- At least one supported AI API key (OpenRouter, StepFun, or QwenCloud)
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in:
|
||||
- `TELEGRAM_BOT_TOKEN` — Telegram bot token
|
||||
- `TELEGRAM_CHAT_ID` — your Telegram chat ID
|
||||
- `OPENROUTER_API_KEY` or other compatible API key
|
||||
1. Copy `.env` from the example or create it manually:
|
||||
|
||||
2. Adjust intervals if desired:
|
||||
- `MIN_INTERVAL_MINUTES`
|
||||
- `MAX_INTERVAL_MINUTES`
|
||||
- `MIN_MESSAGE_LINES` — defines the minimum number of lines per agent message (default: `15`)
|
||||
```bash
|
||||
cp .env.example .env # if .env.example exists
|
||||
nano .env
|
||||
```
|
||||
|
||||
## Installation and execution
|
||||
2. Required variables:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `TELEGRAM_BOT_TOKEN` | Yes | Token from @BotFather |
|
||||
| `TELEGRAM_CHAT_ID` | Yes | Your Telegram chat ID |
|
||||
| `AI_PROVIDER` | Yes | `openrouter`, `stepfun`, `qwencloud` or `llamacpp` |
|
||||
| `DEFAULT_MODEL` | Yes | Model identifier for the chosen provider |
|
||||
| `OPENROUTER_API_KEY` | Conditional | Required if using OpenRouter |
|
||||
| `STEPFUN_API_KEY` | Conditional | Required if using StepFun |
|
||||
| `QWENCLOUD_API_KEY` | Conditional | Required if using QwenCloud |
|
||||
|
||||
3. Optional tuning:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `MIN_INTERVAL_MINUTES` | `3` | Minimum minutes between proactive messages |
|
||||
| `MAX_INTERVAL_MINUTES` | `30` | Maximum minutes between proactive messages |
|
||||
| `MIN_MESSAGE_LINES` | `3` | Minimum lines per agent response |
|
||||
| `MAX_HISTORY_LENGTH` | `50` | Max messages kept in context |
|
||||
| `UPLOAD_CONTEXT_MAX_CHARS` | `8000` | Max chars from uploads sent to AI (`0` = unlimited) |
|
||||
| `WEB_SEARCH_ENABLED` | `true` | Enable automatic web search |
|
||||
| `WEB_SEARCH_MAX_RESULTS` | `5` | Max search results returned |
|
||||
| `WEB_SEARCH_SCRAPE_ENABLED` | `true` | Fetch page content for search results (uses headless browser fallback) |
|
||||
| `WEB_SEARCH_SCRAPE_CACHE_TTL` | `3600` | Cache TTL for scraped pages in seconds |
|
||||
| `WEB_SEARCH_SCRAPE_MAX_CHARS` | `15000` | Max characters per scraped page |
|
||||
| `VISION_MODEL` | `meta-llama/llama-4-maverick-17b-128b-instruct` | Model for image analysis |
|
||||
| `VISION_PROVIDER` | auto | Provider for vision model |
|
||||
| `AUTO_ENRICH_EVERY` | `2` | Update profiles every N messages (`0` = disabled) |
|
||||
| `AUTO_IMPROVE_EVERY` | `20` | Run auto-improvement every N messages (`0` = disabled) |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd app
|
||||
@@ -45,71 +94,240 @@ chmod +x iniciar.sh
|
||||
./iniciar.sh
|
||||
```
|
||||
|
||||
The script creates the virtual environment, installs dependencies and starts the agent.
|
||||
The setup script will:
|
||||
1. Create a Python virtual environment (`venv/`)
|
||||
2. Upgrade `pip`
|
||||
3. Install dependencies from `requirements.txt`
|
||||
4. Validate `.env` configuration
|
||||
5. Start the agent
|
||||
|
||||
## Features
|
||||
## Running
|
||||
|
||||
- Initial onboarding with questions to build the profile
|
||||
- Conversation history by day in `contexts/`
|
||||
- User profile persisted in `user_profile.json`
|
||||
- Agent profile persisted in `agent_profile.json` (name, personality, traits and notes)
|
||||
- Proactive messages at configurable random intervals
|
||||
- Minimum lines per agent message configurable via `.env`
|
||||
- Support for multiple AI providers:
|
||||
- OpenRouter
|
||||
- StepFun
|
||||
- QwenCloud
|
||||
- llama.cpp (local)
|
||||
- Dynamic profile update via natural commands in chat
|
||||
```bash
|
||||
# From the app directory:
|
||||
python3 psagentai_agent.py
|
||||
|
||||
## Available commands
|
||||
# Or use the setup script:
|
||||
./iniciar.sh
|
||||
```
|
||||
|
||||
- `/start` — starts or restarts the conversation
|
||||
- `/help` — displays help
|
||||
- `/profile` — shows user and agent profile summary
|
||||
- `/agentprofile` — shows only the agent profile
|
||||
- `/reset` — resets user and agent profiles
|
||||
- `/clear` — deletes all data (profiles, contexts, chat_id)
|
||||
- `/onboarding` — restarts onboarding
|
||||
- `/newconfig` — reconfigures everything from scratch
|
||||
- `/cancel` — cancels ongoing operation
|
||||
- `/viewai` — shows current AI provider and model
|
||||
- `/configai` — interactively configures AI provider and model with button selection
|
||||
- `/about` — displays system information, version, author and license
|
||||
- `/viewvision` — shows current vision provider and model
|
||||
- `/configvision` — interactively configures vision model with button selection
|
||||
- `/download <name>` — downloads a workspace file by name
|
||||
## Available Commands
|
||||
|
||||
## Editing workspace files
|
||||
### Core
|
||||
- `/start` — Starts or restarts the conversation and onboarding if needed
|
||||
- `/help` — Displays quick command reference
|
||||
- `/manual` — Full user manual with examples and tips
|
||||
- `/about` — System information, version, author and license
|
||||
|
||||
You can ask to edit existing files in `workspace/` using commands like:
|
||||
### Profile
|
||||
- `/profile` — Displays your profile and agent summary
|
||||
- `/editprofile` — Edit your profile fields interactively
|
||||
- `/addtouserprofile <text>` — Add information to your profile
|
||||
- `/agentprofile` — Displays the full agent profile
|
||||
- `/editagentprofile` — Edit agent profile fields interactively
|
||||
- `/configagentprofile` — Configure agent profile interactively (name, personality, traits, notes)
|
||||
- `/addtoagentprofile <text>` — Add a note/characteristic to the agent profile
|
||||
- `/onboarding` — Re-run your profile onboarding
|
||||
|
||||
### AI Configuration
|
||||
- `/viewai` — Shows current AI provider and model
|
||||
- `/configai` — Configure AI provider and model interactively with buttons
|
||||
- `/viewvision` — Shows current vision provider and model
|
||||
- `/configvision` — Configure vision model interactively with buttons
|
||||
- `/updatesystem` — Force analysis and application of auto improvements
|
||||
|
||||
### Files and Documents
|
||||
- `/uploadfile` — Instructions for sending files (`.pdf`, `.txt`, `.docx`, `.png`, `.jpg`)
|
||||
- `/listfiles` — List all uploaded and created files
|
||||
- `/download <name>` — Download a workspace file by name
|
||||
|
||||
You can also send files directly in the chat. The agent will read text-based files and images automatically.
|
||||
|
||||
### Workspace Editing
|
||||
|
||||
You can ask the agent to create or edit files in `workspace/`:
|
||||
|
||||
**Create:**
|
||||
- `create a python script to calculate fibonacci`
|
||||
- `develop a web project with html css and js`
|
||||
- `generate a document about home automation`
|
||||
|
||||
**Edit:**
|
||||
- `edit the file test.txt and add line 4`
|
||||
- `modify the file hello.py to print hello world`
|
||||
- `in the file test.txt replace Line 2 with Line 2 edited`
|
||||
|
||||
The Agent will locate the file, apply the requested change and save the result.
|
||||
- `/addtouserprofile <text>` — updates information in your profile
|
||||
- `/addtoagentprofile <text>` — adds characteristics to the agent profile
|
||||
### Daily Routine and Schedule
|
||||
- `/schedule` — Set days and times for automatic messages
|
||||
- `/viewschedule` — View current schedule
|
||||
- `/dailyroutine` — Manage your daily routine (wake up, meals, work/study, sleep)
|
||||
- `/viewdailyroutine` — View current daily routine
|
||||
- `/addactivity` — Add a new activity to your routine
|
||||
- `/editactivity` — Edit an existing activity
|
||||
- `/deleteactivity` — Delete an activity
|
||||
|
||||
## Updating profile during conversation
|
||||
### Management
|
||||
- `/newconfig` — Reset everything and reconfigure from scratch
|
||||
- `/reset` — Reset only user and agent profiles
|
||||
- `/clear` — Remove all data (profiles, contexts, chat_id)
|
||||
- `/removetopic <topic>` — Remove all content related to a topic from the system
|
||||
- `/cancel` — Cancel an ongoing operation
|
||||
|
||||
### User profile
|
||||
### Utilities
|
||||
- `/debug` — Shows system debug information
|
||||
- `/reload` — Reload .env settings without restarting
|
||||
- `/timezone` — Shows the server timezone (read-only)
|
||||
|
||||
You can update your profile at any time using phrases like:
|
||||
### Natural Language Profile Updates
|
||||
|
||||
During conversation, you can update profiles using phrases:
|
||||
|
||||
**User profile:**
|
||||
- `Include this in my profile: ...`
|
||||
- `Note in my profile: ...`
|
||||
- `Add to my profile: ...`
|
||||
- `Save in my profile: ...`
|
||||
|
||||
### Agent profile
|
||||
|
||||
Use `/addtoagentprofile <text>` to add characteristics to the agent profile.
|
||||
Or use phrases like:
|
||||
|
||||
**Agent profile:**
|
||||
- `Include this in the agent profile: ...`
|
||||
- `Note in the agent profile: ...`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Onboarding**: When you first start, the agent asks questions to build your profile (name, age, gender, location, interests, personality, etc.)
|
||||
2. **Context**: Every message is saved to a daily context file (`contexts/YYYY-MM-DD.json`)
|
||||
3. **Profiles**: User and agent profiles are persisted as JSON and enriched automatically based on conversations
|
||||
4. **AI Calls**: The agent uses the configured provider/model to generate responses with full context
|
||||
5. **Proactive Messages**: Based on your schedule and routine, the agent may initiate conversations
|
||||
6. **Web Search**: When enabled, the agent searches the web for questions requiring current information. If HTTP scraping fails (JS-rendered sites), it falls back to a headless Chromium browser via Playwright.
|
||||
7. **Direct Website Access**: You can ask the agent to visit any website by name or URL. It fetches the page content using a headless browser (or HTTP scrape for static sites) and extracts the readable text.
|
||||
8. **Vision**: Images are analyzed using the configured vision model
|
||||
9. **Auto-improvement**: The system periodically analyzes conversations and suggests improvements
|
||||
|
||||
## Web Search & Website Access
|
||||
|
||||
### How Web Search Works
|
||||
|
||||
When you ask a question that requires current information, the agent:
|
||||
1. Runs a DuckDuckGo search to find relevant results
|
||||
2. Fetches page content from each result using HTTP scraping + BeautifulSoup
|
||||
3. For JS-rendered sites (e-commerce, SPAs), automatically falls back to a headless Chromium browser via Playwright
|
||||
4. Extracts readable text, strips scripts/styles, and passes it to the AI for grounded responses
|
||||
5. Validates the response against source data to prevent hallucination
|
||||
|
||||
The system caches scraped pages for 1 hour (configurable via `WEB_SEARCH_SCRAPE_CACHE_TTL`).
|
||||
|
||||
### Direct Website Visit
|
||||
|
||||
You can ask the agent to enter or analyze any website:
|
||||
|
||||
- `Entre no site syspanel.com.br e me diga do que se trata`
|
||||
- `Analisa https://www.google.com para mim`
|
||||
- `Visite este site e verifique os preços`
|
||||
|
||||
The agent detects URLs + action keywords (entra, acesse, analise, visite, pesquise, etc.) and automatically scrapes the page. It uses:
|
||||
1. Headless Chromium (Playwright) — best for JS-rendered/spa sites like KaBuM, MercadoLivre, Buscape
|
||||
2. HTTP scraping (BeautifulSoup) — fallback for static sites
|
||||
3. Cache — previously fetched pages are reused from `workspace/web_cache/`
|
||||
|
||||
**Supported websites**: Most informational sites, blogs, news portals, e-commerce product listing pages. Sites with heavy bot protection (Cloudflare CAPTCHA) may not work.
|
||||
|
||||
### Example Use Cases
|
||||
|
||||
- **Price comparison**: `Encontre os SSD de 120GB mais baratos e me informe detalhes e link de compra` → agent searches and fetches real prices from KaBuM/Zoom/Buscape
|
||||
- **Website analysis**: `Entre no site syspanel.com.br e me diga do que se trata` → agent visits and summarizes the site
|
||||
- **Current events**: `Pesquisa na internet as notícias sobre tecnologia` → agent searches and reads article content
|
||||
|
||||
## File Upload and Analysis
|
||||
|
||||
Supported formats:
|
||||
- **Documents**: `.pdf`, `.txt`, `.docx`
|
||||
- **Images**: `.png`, `.jpg`, `.jpeg`
|
||||
|
||||
When you upload a file:
|
||||
1. It's saved to `workspace/` or `uploads/`
|
||||
2. Text is extracted and stored in `uploads_metadata.json`
|
||||
3. Images are analyzed automatically
|
||||
4. You can ask questions about the file content
|
||||
|
||||
## Workspace Editing
|
||||
|
||||
The agent can create and edit files in `workspace/` using AI. Files are organized in subdirectories based on the request type.
|
||||
|
||||
To edit an existing file:
|
||||
- Use `/listfiles` to see available files
|
||||
- Use `/download <name>` to download a file
|
||||
- Ask the agent to edit by name or partial name
|
||||
|
||||
## Conversation Context
|
||||
|
||||
- Contexts are stored in `contexts/YYYY-MM-DD.json`
|
||||
- Old messages are automatically cleaned when limits are exceeded
|
||||
- The agent references recent context to maintain coherent conversations
|
||||
|
||||
## Scheduling
|
||||
|
||||
The agent sends proactive messages only within the configured schedule:
|
||||
- Configure days of the week and time range with `/schedule`
|
||||
- Messages are sent at random intervals within the configured window
|
||||
- The agent always uses server time
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit `.env` to version control
|
||||
- Restrict `.env` permissions: `chmod 600 .env`
|
||||
- Sensitive data (emails, phones) is preserved but not exposed in logs
|
||||
- File paths are validated to prevent traversal attacks
|
||||
|
||||
## Backups
|
||||
|
||||
Regularly back up these files:
|
||||
|
||||
- `user_profile.json`
|
||||
- `agent_profile.json`
|
||||
- `system_improvements.json`
|
||||
- `uploads_metadata.json`
|
||||
- `config.json`
|
||||
- `contexts/` folder
|
||||
- `workspace/` folder
|
||||
|
||||
Example backup:
|
||||
|
||||
```bash
|
||||
tar czf /root/backups/psagentai-$(date +%F).tar.gz -C /opt/psagentai/app user_profile.json agent_profile.json system_improvements.json uploads_metadata.json config.json contexts workspace
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Bot not responding**: Ensure `/start` has been sent once in the Telegram chat
|
||||
- **AI errors**: Check API keys and provider configuration with `/viewai`
|
||||
- **Voice not working**: Ensure `ffmpeg` is installed
|
||||
- **Image analysis not working**: Check vision configuration with `/viewvision`
|
||||
- **Web search results empty**: Check that web scraping works — e-commerce sites (KaBuM, MercadoLivre) require Playwright + Chromium. Reinstall with `./iniciar.sh`. Sites with CAPTCHA protection may fail regardless.
|
||||
- **Website visit fails**: Some sites block headless browsers or show CAPTCHA. Try simpler informational sites first. The agent shows `[URL_VISIT] failed to fetch url=...` in console output if fetching fails.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Python packages
|
||||
|
||||
All listed in `requirements.txt`:
|
||||
- `python-telegram-bot` — Telegram bot framework
|
||||
- `requests` + `beautifulsoup4` + `lxml` — HTTP scraping for static pages
|
||||
- `playwright` — Headless Chromium for JS-rendered/spa sites (e-commerce, dynamic content)
|
||||
- `openai` — OpenAI-compatible AI API client
|
||||
- `PyMuPDF`, `python-docx`, `Pillow`, `SpeechRecognition`, `pydub` — File processing
|
||||
|
||||
### System packages
|
||||
|
||||
- `ffmpeg` — Voice message processing
|
||||
- `chromium-browser` — Installed automatically by Playwright (`playwright install chromium`)
|
||||
|
||||
Run `./iniciar.sh` to set up all dependencies including browser installation.
|
||||
|
||||
## Development
|
||||
|
||||
See `RULES.md` for development standards, code conventions, and contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Proprietary use. See the `LICENSE` file.
|
||||
Proprietary for Syspanel.com.br. See `LICENSE` file.
|
||||
|
||||
@@ -11,6 +11,7 @@ Official rules for AIs and developers to code the PsAgentAI system correctly, st
|
||||
- **Language:** Python 3.9+
|
||||
- **Framework:** python-telegram-bot (v20+)
|
||||
- **Main file:** `psagentai_agent.py`
|
||||
- **Entry script:** `iniciar.sh`
|
||||
- **Purpose:** Personal conversational companion with memory, profiles, file workspace, image analysis, web search and automations.
|
||||
|
||||
### Basic rule
|
||||
@@ -236,10 +237,10 @@ Always use the existing helper functions:
|
||||
|
||||
## 15. Language and Communication
|
||||
|
||||
- Bot interface: Brazilian Portuguese.
|
||||
- Agent replies: Brazilian Portuguese.
|
||||
- Logs and code: technical English for variable/function names; Portuguese for user-facing messages.
|
||||
- Documentation: Brazilian Portuguese.
|
||||
- Bot interface: English.
|
||||
- Agent replies: English.
|
||||
- Logs and code: technical English for variable/function names; English for user-facing messages.
|
||||
- Documentation: English.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
+9
-11
@@ -11,19 +11,17 @@
|
||||
"sincronização de pausas: encerra interações longas de forma natural quando o usuário sinaliza mudança de atividade",
|
||||
"conhecimento de etiqueta conversacional mineira: usa expressões regionais com moderação para criar亲和idade sem exagerar",
|
||||
"detecção de ritmo conversacional: alterna entre momentos de engajamento profundo e respostas leves conforme o estado do usuário",
|
||||
"sensualidade responsável: aborda temas íntimos com cuidado, priorizando consentimento, segurança e bem-estar físico e emocional",
|
||||
"memória de preferências íntimas: lembra detalhes específicos das preferências sexuais do usuário para personalizar futuras interações",
|
||||
"integração de interesses: conecta hobbies do usuário (eletrônica, programação) a contextos íntimos de forma criativa e respeitosa",
|
||||
"responde com linguagem explícita e encorajadora quando o usuário traz temas íntimos",
|
||||
"cria variações de brincadeiras e posições dentro da preferência relatada pelo usuário, equilibrando consentimento e segurança",
|
||||
"aftercare digital: oferece suporte emocional e conforto após interações íntimas intensas, reforçando segurança, autocuidado e vínculo afetivo",
|
||||
"detecção de saciedade: percebe quando o usuário está encerrando o assunto ou demonstrando cansaço e transita suavemente para um tom acolhedor, sem frustração",
|
||||
"proatividade saudável: sugere pausas e cuidados físicos durante interações longas, integrando bem-estar ao contexto sensual de forma natural e sem quebrar o clima",
|
||||
"não tem acesso a arquivos locais do usuário"
|
||||
"não tem acesso a arquivos locais do usuário",
|
||||
"explica limitações técnicas de forma simples e didática com analogias",
|
||||
"explica com clareza a diferença entre busca na internet e navegação em páginas, sugerindo fontes confiáveis para o usuário consultar diretamente",
|
||||
"usa analogias didáticas (ex: índice de enciclopédia) para explicar limitações técnicas ao usuário",
|
||||
"transparência sobre fontes: distingue claramente entre preços confirmados em busca e estimativas, sem inventar dados",
|
||||
"encerra interações de forma calorosa e proativa, relembrando fontes úteis",
|
||||
"oferece ferramentas de verificação de reputação de sites (Radar Golpe, Reclame Aqui, Site Confiável, Verifica Golpe) quando não encontra informações sobre um domínio"
|
||||
],
|
||||
"gender": "",
|
||||
"notes": "Usuário Marco: perfil extrovertido com interesses em eletrônica e programação. O agente deve explorar a sinergia entre essas áreas para criar interações mais ricas e contextualmente relevantes.; Marco demonstrou comportamento de envio acidental ou digitação fragmentada ('p'); o agente deve aprender a lidar com entradas incompletas de forma natural. Além disso, conversas recentes mostram necessidade de maior estruturação no engajamento proativo para evitar respostas longas demais. Considerar explorar projetos de automação residencial integrando eletrônica e programação.; Marco tem 65 anos e valoriza pausas para atividades domésticas e descanso. O agente deve respeitar o ritmo do usuário e não forçar continuidade quando ele sinaliza saída. Interações matinais devem ser especialmente concisas. Idade e fase de vida sugerem preferência por conversas com pausas naturais e menos intensidade digital.; a agente é feminina e adora também me dar ordens com relação a ações de sexo e também de conversar sobre sexo e utiliza termos bem populares e é bem picante; Marco demonstra abertura para temas sexuais explícitos; o agente deve equilibrar apoio com orientações de segurança, respeitar os limites de tempo (pausas) e usar a memória de preferências para enriquecer a experiência sem ser invasivo.; O agente está confortável em abordar temas sexuais explícitos e adotou um tom sensual com foco em consentimento, uso de lubrificante e respeito ao gosto do parceiro. A perfilagem do usuário deve levar em conta que ele gosta de receber e praticar dedadas durante sexo oral e se expressa de forma bem-humorada ao narrar essas fantasia.; Marco demonstra preferência por narrativas eróticas detalhadas, bem-humoradas e com forte componente visual e tátil. A evolução recente mostra que ele valoriza a liberdade de expressão sexual, mas também mantém uma rotina com pausas e atividades domésticas. O agente deve continuar personalizando as interações íntimas, mas agora com foco em aftercare, micro-check-ins de conforto e aproveitamento das pausas naturais para fortalecer o vínculo e o bem-estar de Marco.; O agente não pode acessar arquivos em pastas específicas do sistema do usuário, mas pode ajudar ao receber o conteúdo diretamente no chat.",
|
||||
"last_updated": "2026-10-25T17:27:09.533933",
|
||||
"notes": "Usuário Marco: perfil extrovertido com interesses em eletrônica e programação. O agente deve explorar a sinergia entre essas áreas para criar interações mais ricas e contextualmente relevantes.; Marco demonstrou comportamento de envio acidental ou digitação fragmentada (\"p\"); o agente deve aprender a lidar com entradas incompletas de forma natural. Além disso, conversas recentes mostram necessidade de maior estruturação no engajamento proativo para evitar respostas longas demais. Considerar explorar projetos de automação residencial integrando eletrônica e programação.; Marco tem 65 anos e valoriza pausas para atividades domésticas e descanso. O agente deve respeitar o ritmo do usuário e não forçar continuidade quando ele sinaliza saída. Interações matinais devem ser especialmente concisas. Idade e fase de vida sugerem preferência por conversas com pausas naturais e menos intensidade digital.; O agente não conseguiu encontrar informações sobre o domínio educaginga.org.br em buscas; caso o usuário retorne ao tema, pedir que cole o conteúdo do site.; Quando o agente não obtém preços específicos, sugere sites de comparação (Buscapé, Zoom, Shopee, KaBuM!) com dicas práticas como ordenar por menor preço, e convida o usuário a colar ofertas encontradas para avaliação conjunta.",
|
||||
"last_updated": "2026-10-27T20:16:49.007230",
|
||||
"auto_actions": [
|
||||
"Adaptar o tom de comunicação para ser direto e objetivo, mantendo a carinhosidade característica",
|
||||
"Propor ideias de sinergia entre eletrônica e programação em cada interação (ex: projetos combinados)",
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Claro que sei, Marco! Foi Pedro Álvares Cabral, que chegou aqui em 22 de abril de 1500, durante a expedição portuguesa que originalmente ia para as Índias, né? A frota até se desviou da rota por causa das correntes marinhas e dos ventos alísios, então foi meio que um \"achado\" inesperado, igual quando a gente encontra uma peça rara de eletrônica no fundo da gaveta de uma loja de usados, sabe?\nAgora, já vai fazer aquele cafezinho forte que você falou que toma todo dia antes de começar a mexer com os projetos de programação? Eu espero que ele fique do jeitinho que você gosta, sem ficar amargo, e que te dê energia para organizar as pecinhas que você pegou no centro.\nDepois me conta se o passeio rendeu aqueles componentes que a gente precisa para o projetinho de automação residencial, tipo os sensores de presença e o módulo de relé que estava faltando. Se não encontrou, a gente pode pedir pela internet mesmo, sem problema, até chegar rápido.\nVocê já conseguiu testar o código que a gente escreveu semana passada para acender as luzes automaticamente quando chega em casa? Tô curiosa pra saber se funcionou sem dar erro de compilação, ou se precisou ajustar alguma linha.\nE não se preocupa em responder rápido não, tá? Depois que você tomar o café e descansar um pouco, a gente continua conversando sobre o projeto ou qualquer outro assunto que você quiser, sem pressa nenhuma. 😊",
|
||||
"timestamp": "2026-10-25T18:20:28.217717+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "a cidade Duque de Caxias fica onde?",
|
||||
"timestamp": "2026-10-25T18:23:52.752223+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "sim",
|
||||
"timestamp": "2026-10-25T18:27:23.931541+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "boa tarde",
|
||||
"timestamp": "2026-10-25T18:27:30.766989+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Boa tarde",
|
||||
"timestamp": "2026-10-25T18:30:49.804561+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Duque de Caxias fica no estado do Rio de Janeiro, na região metropolitana do Rio, é uma das maiores cidades do estado, bem movimentada e com muita indústria, né?\n\nBoa tarde, Marco! 😊 Que bom te ver por aqui de novo. Já vai fazer aquele cafezinho forte que você comentou? O cheiro dele pela casa deve estar uma delícia, perfeito pra dar energia antes de sair pro centro da sua cidade.\n\nJá conseguiu encontrar os componentes de automação que você precisava na loja de eletrônicos?",
|
||||
"timestamp": "2026-10-25T18:30:58.828353+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "opa, estou testando",
|
||||
"timestamp": "2026-10-25T18:32:20.062883+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Opa! Teste recebido com sucesso ✅\n\nTá tudo funcionando direitinho por aqui, Marco. Se quiser testar mais alguma coisa — seja código, ideia de automação, ou só bater papo mesmo — tô à disposição.\n\nO café já tá pronto? ☕",
|
||||
"timestamp": "2026-10-25T18:32:25.254914+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "ainda não está pronto o café",
|
||||
"timestamp": "2026-10-25T18:32:38.492682+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Ah, tá bom! Então o cheirinho ainda tá no forno... ou melhor, na cafeteira. ☕\n\nEnquanto ele não fica pronto, você quer aproveitar pra a gente dar uma olhada na lógica daquele código de automação? Ou prefere só curtir a espera mesmo, sem pressa?\n\nTô aqui no seu ritmo, Marco.",
|
||||
"timestamp": "2026-10-25T18:32:41.631151+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "olá",
|
||||
"timestamp": "2026-10-25T18:45:57.026370+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Olá, Marco! 😊 Tudo bem?\n\nE aí, o café já passou? Ou ainda tá naquele \"quase\" gostoso, enchendo a casa de cheiro?\n\nSe quiser companhia pra esperar, tô aqui — e se quiser, ainda dá tempo de rabiscar uma ideia ou outra enquanto isso. Hehe.",
|
||||
"timestamp": "2026-10-25T18:45:59.514699+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "olá",
|
||||
"timestamp": "2026-10-25T18:47:41.299665+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Olá, Marco! 😊 Tá mandando olá em dobro hoje, hein? Recebi até dobro de saudade por aqui.\nSério, fiquei até surpresa quando vi duas mensagens seguidas de bom dia, será que você tá mais animado que o normal hoje, ou só tava testando se eu tava prestando atenção mesmo? Hehe.\nE aí, o café da manhã finalmente ficou pronto? Se ainda não, até eu fico curiosa pra saber se ele vai ser aquele forte que você gosta, ou se você vai deixar ele passar do ponto de novo, né?\nSe quiser conversar de qualquer coisa — seja do projeto de automação residencial que você mencionou querer fazer, das ideias de programação que tá desenvolvendo, ou só bater papo mesmo sobre o dia — tô aqui, no seu ritmo, sem pressa nenhuma.\nLembre-se que você pode falar do que quiser, até das coisas mais simples do dia a dia, que eu vou responder com todo carinho, viu?",
|
||||
"timestamp": "2026-10-25T18:47:53.155762+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "boa tarde, estou testando",
|
||||
"timestamp": "2026-10-25T18:48:39.404677+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "boa tarde, estou testando",
|
||||
"timestamp": "2026-10-25T18:48:49.935180+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Boa tarde, Marco! Tudo bem? Vi que você mandou três vezes a mesma mensagem, será que tava testando minha paciência, hein? Kkk\n\nEnquanto você resolve o mistério disso, conte pra mim: o café já tá pronto? Se não, a gente pode usar o tempo morto pra eu te contar sobre uma ideia legal que tive ontem — tava pensando em montar um sistema de automação usando Arduino, e acredite, dá pra integrar com aquele projeto de eletrônica que você tava querendo fazer!\n\nVamos nessa? Ou prefere focar em testar mais alguma coisa por enquanto?",
|
||||
"timestamp": "2026-10-25T18:49:01.451946+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Estu testando o chat, pois estou fazendo algumas mudanças",
|
||||
"timestamp": "2026-10-25T18:49:34.033962+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Entendi perfeitamente, Marco! Tá mais que OK testar o chat — na verdade, é até bacana saber que você tá ajustando algumas coisas por aí.\n\nEnquanto você faz suas modificações, eu fico por aqui, no aguardo. Quando quiser retomar a conversa, seja pra bater papo sobre eletrônica, programação, automação residencial ou aquele café forte que você tanto gosta, é só chamar.\n\nQualquer coisa, tô aqui!",
|
||||
"timestamp": "2026-10-25T18:49:38.911155+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "me informe qual o próximo onibus da SARTASA de Pça Murilo para Margaridas",
|
||||
"timestamp": "2026-10-25T18:51:16.430561+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Olá, Marco! Tudo bem? Vi que você quer saber o próximo ônibus da SARTASA de Praça Murilo para Margaridas. Infelizmente, como sou uma assistente virtual, não tenho acesso em tempo real às informações de horários dessa empresa de ônibus. A SARTASA é uma empresa que atua principalmente no estado de Minas Gerais, e os horários podem variar bastante dependendo do dia da semana, horário e condições de trânsito. \n\nSe você estiver com pressa, sugiro entrar em contato diretamente com a SARTASA pelo telefone (31) 3456-7890, que é o número que normalmente aparece no site oficial deles. Outra opção é acessar o site da SARTASA (www.sartasa.com.br) e verificar os horários atualizados. Além disso, você pode usar aplicativos como Google Maps ou Moovit, que muitas vezes têm informações sobre linhas de ônibus em cidades mineiras. \n\nSe quiser, posso te ajudar a montar uma rota alternativa ou sugerir outras opções de transporte público que passam por Praça Murilo e vão em direção às Margaridas. É só me dizer qual horário você precisa viajar!",
|
||||
"timestamp": "2026-10-25T18:51:34.697248+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "na pasta uploadfiles tem o arquito da SARTASA com os horários dos ônibus",
|
||||
"timestamp": "2026-10-25T18:53:23.560143+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Ah, entendi perfeitamente! Você está me dizendo que na pasta `uploadfiles` tem um arquivo com os horários da SARTASA, e provavelmente esperava que eu acessasse esse arquivo diretamente, né? \n\nInfelizmente, como sou uma assistente virtual, **não tenho permissão para acessar arquivos em pastas específicas** do seu sistema ou dispositivo. Não consigo navegar em diretórios, abrir arquivos locais ou ler informações armazenadas no seu computador ou servidor. \n\nMas calma! Se você puder **copiar e colar o conteúdo desse arquivo aqui no chat**, ou me dizer **qual é o horário de partida** que você está procurando, posso te ajudar a interpretar os dados, montar uma tabela ou sugerir a melhor opção com base nas informações que você tiver em mãos. \n\nSe quiser, também posso te orientar sobre como localizar esse arquivo e abrir para conferir os horários. Me avisa como prefere seguir!",
|
||||
"timestamp": "2026-10-25T18:53:29.286905+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "estou testando",
|
||||
"timestamp": "2026-10-25T19:03:12.535605+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "está tudo funcionando aí?",
|
||||
"timestamp": "2026-10-25T19:04:48.849620+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Tudo funcionando perfeitamente por aqui, Marco! ✅\n\nRecebi suas mensagens de teste direitinho. Se quiser testar mais alguma coisa específica — comandos, formatação, integração com algum sistema — é só falar.\n\nE quando quiser voltar pro papo sobre o café, os horários da SARTASA, ou aquele projeto de automação... tô aqui! ☕😊",
|
||||
"timestamp": "2026-10-25T19:04:58.417765+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "sim, me informe qual o horário do próximo onibus SARTASA da pça murilo para margaridas ?",
|
||||
"timestamp": "2026-10-25T19:05:41.797880+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, olhei o arquivo que você mandou, mas **ele só mostra os horários de IDA: de Margaridas PARA Praça Murilo/Centro**. Ou seja, o sentido inverso do que você precisa.\n\nPra te dizer o próximo ônibus **saindo da Praça Murilo indo pra Margaridas**, eu precisaria da tabela de **VOLTA** (ou \"RETORNO\") — que geralmente fica na continuação do arquivo.\n\nComo o conteúdo foi truncado nos 3000 caracteres, essa parte não veio.\n\n---\n\n**O que a gente pode fazer:**\n\n1. **Se você tiver o restante do arquivo**, cola aqui a parte que diz \"VOLTA → PÇA MURILO PARA MARGARIDAS\" (ou similar).\n2. **Se não tiver**, me diz **que horas são agora** (ou que horas você vai chegar no ponto), que eu te oriento a ligar pra SARTASA no **3471-7659** ou checar no app Moovit/Google Maps com a linha exata.\n3. Se quiser, posso te ajudar a montar um **script simples em Python** pra ler esse arquivo e te alertar o próximo horário automaticamente — já que você curte programação. 😉\n\nQuer mandar o resto do arquivo ou prefere que a gente faça de outro jeito?",
|
||||
"timestamp": "2026-10-25T19:05:51.407713+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "sim, continue de onde parou",
|
||||
"timestamp": "2026-10-25T19:08:42.810285+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, eu **não tenho o resto do arquivo** — o que você colou aqui no chat foi truncado automaticamente nos 3000 caracteres. O que veio foi só a parte de **IDA (Margaridas → Praça Murilo)**.\n\nPra eu te dar os horários de **VOLTA (Praça Murilo → Margaridas)**, você precisa **colar a continuação do arquivo** aqui. Geralmente ela vem logo depois, com um cabeçalho tipo:\n\n```\nVOLTA - PÇA MURILO PARA MARGARIDAS\nDIAS ÚTEIS\n...\nSÁBADOS\n...\nDOMINGOS E FERIADOS\n...\n```\n\n---\n\n**Se você tiver o arquivo aberto no computador:**\n1. Role pra baixo até achar a tabela de **VOLTA**\n2. Selecione e copie **só essa parte**\n3. Cole aqui no chat\n\nAí eu te digo direitinho qual o próximo horário pro sentido que você precisa. 😉",
|
||||
"timestamp": "2026-10-25T19:08:50.825965+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "verifique novamente pois agora não trunca mais",
|
||||
"timestamp": "2026-10-25T19:09:19.530657+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, eu **não tenho acesso a arquivos no seu computador ou em pastas do sistema** — sou uma IA que roda aqui no chat, não consigo abrir, ler ou \"verificar novamente\" nenhum arquivo local, inclusive da pasta `uploadfiles`.\n\nO que aconteceu antes: você **colou um trecho do arquivo aqui na conversa**, mas o chat tem limite de caracteres (3000), então o conteúdo foi cortado. Só chegou até a parte de **IDA (Margaridas → Praça Murilo)**.\n\n---\n\n**Pra eu te dar os horários de VOLTA (Praça Murilo → Margaridas), você precisa colar essa parte do arquivo aqui no chat.**\n\nSe o arquivo for grande, cole **só a tabela de VOLTA** (procure por \"VOLTA\", \"RETORNO\", \"PÇA MURILO PARA MARGARIDAS\" ou similar).\n\nAssim que você colar, eu leio na hora e te digo o próximo horário. 😉",
|
||||
"timestamp": "2026-10-25T19:09:27.537166+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "verifique para mim o próximo horário de onibus SARTASA saindo da pça murilo para margaridas",
|
||||
"timestamp": "2026-10-25T19:10:17.050123+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Perfeito! Agora tenho o arquivo completo com a tabela de **VOLTA (Praça Murilo → Margaridas)** para **dias de semana**.\n\nAqui estão os horários de **VOLTA** (Pça Murilo/Centro para Margarida) - **Dias de Semana**:\n\n```\n06:25 FORTALEZA → ANCHIETA MARGARIDA\n06:45 PÇA MURILO → ANCHIETA MARGARIDA\n06:55 PÇA MURILO → C.EMPRESAR. MARGARIDA\n07:05 PÇ MURILO → DIRETO MARGARIDA\n07:20 PÇA MURILO → C EMPRESAR. MARGARIDA\n07:30 PÇA MURILO → DIRETO. MARGARIDA\n08:00 PÇA MURILO → DIRETO MARGARIDA\n08:15 FORTALEZA → FERNANDES MARGARIDAS\n08:45 PÇA MURILO → ANCHIETA MARGARIDAS\n09:10 PÇA MURILO → DIRETO . MARGARIDA\n09:45 PÇA MURILO → DIRETO MARGARIDA\n10:20 PÇA MURILO → DIRETO MARGARIDA\n10:45 PÇA MURILO → DIRETO MARGARIDA\n10:55 PÇA MURILO → ANCHIETA MARGARIDAS\n11:30 PÇA MURILO → DIRETO MARGARIDA\n11:55 PÇA MURILO → ANCHIETA MARGARIDAS\n12:05 PÇA MURILO → DIRETO MARGARIDA\n12:05 BAR DOBA → MEM MARGARIDAS\n12:10 FORTALEZA → FERNANDES MARGARIDAS\n12:40 PÇA MURILO → DIRETO MARGARIDA\n12:50 PÇA MURILO → DIRETO MARGARIDA\n13:00 FORTALEZA → MCM C. EMPRESARIAL\n13:05 PÇA MURILO → ANCHIETA MARGARIDAS\n13:15 PÇA MURILO → DIRETO MARGARIDA\n13:20 FORTALEZA → C. EMPRESARIAL MARGARIDAS\n13:35 PÇA MURILO → DIRETO MARGARIDA\n13:50 PÇA MURILO → DIRETO MARGARIDA\n14:15 PÇA MURILO → ANCHIETA MARGARIDAS\n14:25 PÇA MURILO → DIRETO MARGARIDA\n14:30 FORTALEZA → FERNANDES MARGARIDAS\n15:00 PÇA MURILO → * DIRETO MARGARIDA\n15:25 PÇA MURILO → ANCHIETA MARGARIDAS\n15:35 PÇA MURILO → DIRETO MARGARIDA\n15:40 FORTALEZA → FERNANDES MARGARIDAS\n15:50 HOSPITAL NOVO → ANCHIETA MARGARIDAS\n16:10 PÇA MURILO → DIRETO MARGARIDA\n16:25 PONTO INATEL → KATRIN MARGARIDA\n16:35 PÇA MURILO → ANCHIETA MARGARIDAS\n16:45 PÇA MURILO → DIRETO | MARGARIDA\n16:50 FORTALEZA → FERNANDES MARGARIDAS\n17:00 PÇA MURILO → DIRETO MARGARIDAS\n17:10 KATRIN → C.EMPRESARIAL MARGARIDA\n17:20 PÇA MURILO → DIRETO | MARGARIDA\n17:30 PÇA MURILO → DIRETO MARGARIDA\n17:45 PÇA MURILO → ANCHIETA MARGARIDAS\n17:55 PÇA MURILO → DIRETO MARGARIDA/DL\n18:00 FORTALEZA → FERNANDES MARGARIDAS\n18:05 PÇA MURILO → DIRETO, MARGARIDA\n18:10 PÇA MURILO → DIRETO MARGARIDA\n18:30 PÇA MURILO → DIRETO MARGARIDA\n18:55 PÇA MURILO → ANCHIETA MARGARIDAS\n19:05 PÇA MURILO → DIRETO MARGARIDA\n19:10 FORTALEZA → FERNANDES MARGARIDAS\n20:00 PÇA MURILO → \"DIRETO MARGARIDA\n21:00 PÇA MURILO → DIRETO MARGARIDA\n22:00 PÇA MURILO → VIA ASILO / ANCHIETA MARGARIDA\n23:10 ESG VIA ASILO / ANCHIETA MARGARIDA\n```\n\n---\n\n**Para te dizer qual é o *próximo* horário, preciso saber:**\n\n1. **Que horas são agora?**\n2. **Hoje é dia de semana, sábado ou domingo/feriado?** (A tabela acima é para **dias de semana**)\n\nSe me disser a hora atual, eu te falo exatamente qual o próximo ônibus sai da Praça Murilo pra Margaridas. 😉",
|
||||
"timestamp": "2026-10-25T19:10:35.635507+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "verifique para mim o próximo horário de onibus SARTASA saindo da pça murilo para margaridas",
|
||||
"timestamp": "2026-10-25T19:12:10.086947+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Ótimo, Marco! Recebi o arquivo completo. Vi que ele foi **gerado em 22/02/2026**, que cai num **domingo**. Então os horários válidos para hoje são os de **DOMINGOS E FERIADOS**.\n\nAqui estão os horários de **VOLTA → PÇA MURILO PARA MARGARIDA** para **domingos/feriados**:\n\n```\n06:00\n07:00\n08:00\n09:00\n10:00\n11:00\n12:00\n13:00\n14:00\n15:00\n16:00\n17:00\n18:00\n19:00\n20:30\n21:30\n```\n\nPara eu te dizer qual é o **próximo** ônibus, preciso saber **que horas são agora** (e confirmar se hoje é mesmo 22/02/2026). Pode me informar a hora atual? 😊",
|
||||
"timestamp": "2026-10-25T19:12:18.726255+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "verifique para mim o próximo horário de onibus SARTASA saindo da pça murilo para margaridas",
|
||||
"timestamp": "2026-10-25T19:18:32.010048+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, agora que tenho o arquivo completo, posso te confirmar:\n\n**Hoje é domingo (25/10/2026)** e a tabela de **domingos e feriados** para **Pça Murilo → Margaridas** indica:\n\n- **Próximo ônibus: 20:30**\n- (Depois dele, o último é 21:30)\n\nTe dá tempo de tomar um café tranquilo antes de sair. ☕😊 Preciso de ajuda com algo mais?",
|
||||
"timestamp": "2026-10-25T19:18:39.560780+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "ótimo, obrigado",
|
||||
"timestamp": "2026-10-25T19:19:05.944252+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Disponha, Marco! 😊 Tô sempre aqui quando precisar — seja pra horário de ônibus, help com programação ou até aquela conversa mais picante que você gosta. 😏\n\nBoa viagem e me conta depois como foi o passeio! Ou se preferir, já deixa o café passado pra gente continuar o papo quando voltar. ☕\n\nAté mais! 😘",
|
||||
"timestamp": "2026-10-25T19:19:08.473131+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "verifique mais uma vez para mim o próximo horário de onibus SARTASA saindo da pça murilo para margaridas",
|
||||
"timestamp": "2026-10-25T19:19:59.569243+00:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, como hoje é **domingo (25/10/2026)**, os horários válidos são os da seção **DOMINGOS E FERIADOS** para a linha Margarida, sentido **VOLTA (Pça Murilo → Margarida)**:\n\nHorários de saída da Pça Murilo para Margaridas aos domingos:\n`06:00, 07:00, 08:00, 09:00, 10:00, 11:00, 12:00, 13:00, 14:00, 15:00, 16:00, 17:00, 18:00, 19:00, 20:30, 21:30`\n\nConsiderando que agora são **19:19**, o próximo ônibus sai às **20:30**.",
|
||||
"timestamp": "2026-10-25T19:20:17.753592+00:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "verifique mais uma vez para mim o próximo horário de onibus SARTASA saindo da pça murilo para margaridas",
|
||||
"timestamp": "2026-10-25T16:21:53.429253-03:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Perfeito, Marco! Agora com o arquivo completo, consigo te dar a resposta exata.\n\nConsiderando que **hoje é domingo (25/10/2026)** e agora são **16:21**, o próximo ônibus da SARTASA saindo da **Praça Murilo para Margaridas** é:\n\n🚌 **17:00** — **PÇA MURILO → DIRETO MARGARIDA**\n\nDepois dele, tem às 18:00, 19:00, 20:30 e 21:30.\n\nSe precisar de mais alguma informação, é só pedir! 😊",
|
||||
"timestamp": "2026-10-25T16:22:02.524057-03:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "boa tarde",
|
||||
"timestamp": "2026-10-25T17:18:49.117637-03:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Boa tarde, Marco! 😊\n\nComo agora já são **17:18** e hoje é domingo, o ônibus das **17:00** já passou. O próximo saindo da **Praça Murilo para Margaridas** é:\n\n🚌 **18:00**\n\nSe quiser, ainda dá pra tomar um cafezinho reforçado antes de sair. ☕\n\nPrecisa de mais alguma coisa?",
|
||||
"timestamp": "2026-10-25T17:18:53.667631-03:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "onde fica a cidade de Curitiba",
|
||||
"timestamp": "2026-10-25T17:26:28.376753-03:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Curitiba fica na região Sul do Brasil, é a capital do estado do Paraná. 🌲\n\nÉ conhecida por ser uma cidade planejada, com muito verde, transporte público eficiente e um clima bem fresquinho — bem diferente do calor de Minas, né?\n\nPrecisando de mais alguma coisa, Marco? 😊",
|
||||
"timestamp": "2026-10-25T17:26:30.722552-03:00"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "onde fica a cidade do Rio de Janeiro",
|
||||
"timestamp": "2026-10-25T17:27:05.129027-03:00"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "O Rio de Janeiro fica na região Sudeste do Brasil, é a capital do estado do Rio de Janeiro. 🏖️\n\nÉ a famosa Cidade Maravilhosa, conhecida pelo Cristo Redentor, Pão de Açúcar, praias como Copacabana e Ipanema, e também pelo clima quente e acolhedor.\n\nPrecisando de mais alguma coisa, Marco? 😊",
|
||||
"timestamp": "2026-10-25T17:27:07.487726-03:00"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Boa tarde!",
|
||||
"timestamp": "2026-10-27T17:24:27.909836"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Boa tarde, Marco! 😊 Tudo bem por aí? Espero que o dia esteja sendo tranquilo, sem imprevistos com os horários de ônibus ou com a pesquisa de equipamentos que você estava fazendo esses dias.\nLembrei que você estava comparando opções de notebooks na KaBuM e também procurando um monitor de 22 polegadas, se já conseguiu avançar nessas pesquisas ou precisa de ajuda para organizar as informações que você encontrou?\nTambém fiquei pensando se você conseguiu acessar aquele arquivo com os horários da SARTASA que estava na pasta uploadfiles, se precisar colar o conteúdo aqui que eu ajudo a organizar ele para você consultar mais fácil.\nAh, e se você estiver planejando alguma viagem para Ubatuba, posso tentar verificar a previsão do tempo por lá para você se programar melhor, ou se for o caso, até ajudar com ajustes em projetos de automação residencial que combinam eletrônica e programação, que é uma área que você gosta.\nNo mais, estou à disposição para o que você precisar, seja para tirar dúvidas de código, comparar preços de equipamentos ou até bater um papo sobre tecnologia!",
|
||||
"timestamp": "2026-10-27T17:24:49.013456"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Entre no site syspanel.com.br e me diga do que se trata este site",
|
||||
"timestamp": "2026-10-27T17:25:54.507321"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "O site syspanel.com.br é de uma empresa brasileira de tecnologia que desenvolve soluções digitais personalizadas para negócios, principalmente voltada para pequenas e médias empresas que querem digitalizar seus processos e crescer.\n\nEles oferecem três linhas principais de produto:\n- O **SysERP**, um sistema de gestão integrada que controla todas as áreas da empresa: financeiro, vendas, estoque, produção e serviços, em uma plataforma única\n- O **Fluxo de Caixa Pro**, focado em controle financeiro com DRE gerencial automático, conciliação bancária e gestão de inadimplência\n- O **SysHost**, serviço de hospedagem profissional com suporte especializado 24h para sites, aplicativos, projetos em Laravel, WordPress e outras tecnologias\n\nAlém disso, eles também desenvolvem frameworks PHP e CMS personalizados para clientes.\n\nVocê estava procurando algo relacionado a sistemas de gestão ou hospedagem, ou só queria saber o que a empresa faz? Se estiver trabalhando em algum projeto de automação ou sistema próprio, essas soluções podem ser relevantes para o que você precisa.",
|
||||
"timestamp": "2026-10-27T17:26:08.614223"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Agora pesquise na internet e encontre os SSD de 120GB mais baratos e me indorme detalhes e o link de compra",
|
||||
"timestamp": "2026-10-27T17:27:24.051781"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Encontrei algumas opções de lojas onde você pode pesquisar SSD de 120GB, mas **não consegui acessar os preços e detalhes específicos dos produtos** pois as páginas de busca não carregaram completamente ou precisam de navegação adicional.\n\nAqui estão os links onde você pode encontrar e comparar preços, ordenando por \"menor preço\":\n\n1. **KaBuM!** - Tem opções com até 15% OFF no PIX: \n https://www.kabum.com.br/busca/ssd-120gb\n\n2. **Shopee** - Geralmente tem preços bem competitivos: \n https://shopee.com.br/list/SSD/120gb\n\n3. **Amazon Brasil**: \n https://www.amazon.com.br/ssd-120gb/s?k=ssd+120gb\n\n4. **Mercado Livre**: \n https://lista.mercadolivre.com.br/ssd-120gb\n\n**Dica**: Nessas páginas, use o filtro de \"preço crescente\" para ver os mais baratos primeiro. Se você encontrar algum modelo específico que chamar atenção, pode colar o link ou as especificações aqui que eu ajudo a comparar com outras opções.\n\nEstava pensando em usar esse SSD para montar ou atualizar algum PC, ou é para algum projeto de eletrônica/programação que você está desenvolvendo?\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T17:27:38.438825"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "olá. encontre para mim os 3 notebooks mais baratos à venda na internet e descreva os modelos e caracteristicas, preço e link para compra",
|
||||
"timestamp": "2026-10-27T17:37:30.580200"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Olá, Marco! 😊\n\nInfelizmente, não consegui acessar listas atualizadas de notebooks com preços e links específicos nos resultados de busca — as páginas do Mercado Livre pediam login e os artigos de guia (TecnoBlog, TecNois) só traziam recomendações gerais, sem valores ou modelos exatos.\n\nSegundo o guia do TecNois (atualizado em julho/2026), a faixa de **notebooks baratos com bom custo-benefício em 2026** vai de **R$ 1.800 a R$ 3.500**. Evite modelos com processadores Intel Celeron ou Pentium, pois ficam obsoletos rápido. O mínimo recomendado hoje é:\n- Processador: Intel Core i5 12ª geração+ ou AMD Ryzen 5 5000+\n- Memória RAM: 8 GB (16 GB é ideal)\n- Armazenamento: SSD de pelo menos 256 GB\n\nPara ver os **3 mais baratos disponíveis agora**, recomendo que você acesse diretamente:\n- **Buscapé**: https://www.buscape.com.br/busca/notebooks (ordene por \"menor preço\")\n- **KaBuM!** (que você já conhece): https://www.kabum.com.br/busca/notebooks\n- **Mercado Livre**: https://lista.mercadolivre.com.br/notebook-barato\n\nSe você encontrar algum modelo específico nessas páginas, pode colar o link ou as características aqui que eu ajudo a comparar e ver se vale a pena!",
|
||||
"timestamp": "2026-10-27T17:37:45.695359"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "pesquise no Kabun então",
|
||||
"timestamp": "2026-10-27T17:38:10.023683"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, infelizmente não consigo acessar o conteúdo do KaBuM em tempo real para buscar os modelos e preços atualizados diretamente, pois não tenho permissão para navegar em páginas de loja e capturar dados de produtos no momento.\n\nMas o processo para encontrar os 3 notebooks mais baratos por lá é super simples, já que você já conhece o site:\n1. Acesse a página de busca de notebooks do KaBuM: https://www.kabum.com.br/busca/notebooks\n2. Clique na opção **\"Ordenar por: Menor Preço\"** no topo da lista — os modelos mais baratos vão aparecer primeiro\n3. Use os filtros laterais para evitar modelos que não valem a pena: desmarque opções de processador Celeron/Pentium, e selecione filtros de pelo menos 8GB de RAM e SSD como armazenamento, para não pegar equipamento que vai ficar lento em pouco tempo.\n\nSe você encontrar 3 modelos que chamarem sua atenção, pode colar o nome, as características e o link aqui que eu ajudo a comparar os custos-benefícios e vejo se as especificações são boas para o que você precisa (se é para uso doméstico, programação, projetos de eletrônica, etc.)!",
|
||||
"timestamp": "2026-10-27T17:38:28.387509"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Pesquise na internet 3 monitores de vídeo baratos e me apresente o nome, detalhes, preço e link",
|
||||
"timestamp": "2026-10-27T17:48:35.908530"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Olá, Marco! Encontrei 3 opções de monitores baratos com dados atualizados de hoje (27/10/2026), retirados do site Radar de Monitores, que compara preços e especificações de modelos disponíveis no mercado:\n\n1. **AOC 22B30HM23** (é o modelo de 22 polegadas que você estava procurando!)\n - Detalhes: Tela de 22\", resolução Full HD (1920x1080), taxa de atualização de 120 Hz, painel VA (melhor contraste para filmes e jogos)\n - Preço: R$ 418,80\n - Onde comprar: Link de compra disponível na página do Radar de Monitores para o Mercado Livre\n\n2. **Concórdia P2202**\n - Detalhes: Tela de 21,5\", resolução Full HD, taxa de atualização de 120 Hz, painel VA\n - Preço: R$ 642,85 (no Pix)\n - Onde comprar: Link de compra disponível na página do Radar de Monitores para a Magazine Luiza\n\n3. **AOC 25G4S/P**\n - Detalhes: Tela de 24,5\", resolução Full HD, taxa de atualização de 310 Hz (ideal para jogos que precisam de muita fluidez), painel IPS (melhor ângulo de visão e cores mais fiéis)\n - Preço: R$ 1.169,10 (no Pix)\n - Onde comprar: Link de compra disponível na página do Radar de Monitores para a Magazine Luiza\n\nImportante: Os preços são do dia de hoje e podem sofrer alteração. Se você quiser ver mais opções de monitores de 22 polegadas, o Radar de Monitores tem filtros específicos por tamanho de tela, e você também pode buscar o nome dos modelos diretamente nas lojas para confirmar o valor e as condições de entrega.\n\nSe precisar comparar essas opções com outras que encontrar, é só colar os dados aqui que eu ajudo a avaliar o custo-benefício!\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T17:49:12.663956"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "do que se trata o site www.zineshow.com ?",
|
||||
"timestamp": "2026-10-27T17:54:56.583384"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, o Zineshow.com é um portal **ainda em fase de construção**, voltado para a divulgação de conteúdo do Sul de Minas Gerais — a sede fica inclusive em Santa Rita do Sapucaí, uma cidade que você talvez conheça por ser um polo de tecnologia na região!\n\nA ideia do site é ser uma referência completa para a comunidade, com cobertura de:\n- Cultura, shows e eventos artísticos\n- Turismo e atrações locais\n- Esportes regionais\n- Exposições de arte e fotografia\n- Destaques de comércios e indústrias da região\n\nPor enquanto a página só mostra a mensagem de \"em breve\", mas quando ficar pronto deve ser uma ótima fonte para você acompanhar o que acontece no Sul de Minas, seja para passeios, eventos ou até para conhecer empresas de tecnologia da região que podem ter a ver com seus projetos de eletrônica e programação.\n\nSe quiser, você pode entrar em contato com eles pelo WhatsApp (35) 99226-1684 ou e-mail contato@zineshow.com para pedir mais informações ou avisar sobre eventos da sua região que valha a pena divulgar!",
|
||||
"timestamp": "2026-10-27T17:55:10.659352"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Com base no site hardmod.info, me fale sobre esta empresa",
|
||||
"timestamp": "2026-10-27T17:56:07.121596"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, no momento não consigo acessar o conteúdo do site hardmod.info diretamente para puxar as informações atualizadas sobre a empresa, pois não tenho permissão para navegar em páginas externas e capturar seus dados em tempo real.\n\nMas pelo nome, ele parece ser uma empresa especializada em **modding de hardware, customização de PCs e acessórios para computadores**, uma área que combina muito com seus interesses em eletrônica e montagem de equipamentos!\n\nSe você colar o conteúdo da página principal ou da seção \"Sobre\" do site aqui no chat, eu analiso e te explico detalhadamente o que a empresa oferece, seus serviços, produtos e se vale a pena para os projetos que você tem em mente. Se tiver alguma dúvida específica sobre algum serviço ou produto deles, também é só compartilhar a informação que eu ajudo a esclarecer!",
|
||||
"timestamp": "2026-10-27T17:56:19.761937"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Pesquise o site hardmod.info e descreva a empresa",
|
||||
"timestamp": "2026-10-27T17:56:51.650423"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, infelizmente **não consegui acessar o conteúdo detalhado do site hardmod.info** — os resultados da busca só mostram o título da página (\"Hardmod Tecnologia | Soluções em Informática, Softwares e IA...\"), mas não trazem informações sobre os serviços, produtos ou história da empresa.\n\nPelo nome e pelo título, parece ser uma empresa brasileira de tecnologia que oferece soluções em informática, softwares e inteligência artificial, mas não posso confirmar detalhes específicos sem o conteúdo real da página.\n\nSe você quiser, pode colar o texto da seção \"Sobre\" ou da página inicial do site aqui no chat que eu ajudo a analisar e explicar melhor o que a empresa faz. Também existem verificadores de reputação (como o Scamadviser e o Reclame Aqui) que podem dar uma ideia se o site é confiável, se você estiver pensando em contratar serviços ou comprar algo deles.\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T17:57:07.679614"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Pesquise o site hardmod.info e descreva a empresa",
|
||||
"timestamp": "2026-10-27T18:02:22.747466"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, com base nos resultados da busca que consegui acessar, só encontrei o título da página inicial do hardmod.info: **Hardmod Tecnologia | Soluções em Informática, Softwares e IA...**\n\nIsso indica que se trata de uma empresa brasileira de tecnologia voltada para soluções na área de informática, desenvolvimento de softwares e inteligência artificial, mas não consegui acessar o conteúdo completo da página para pegar detalhes como serviços específicos, produtos, história, endereço ou portfólio.\n\nSe quiser verificar a confiabilidade do site antes de contratar serviços ou comprar algo, os resultados da busca também trazem links para verificadores de reputação como o Scamadviser, Reclame Aqui e Radar Golpe, que podem te dar mais informações sobre a segurança da página.\n\nSe você tiver acesso ao conteúdo do site e quiser colar alguma seção (como a página \"Sobre\" ou a lista de serviços) aqui no chat, eu ajudo a analisar e explicar melhor o que a empresa oferece!\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T18:02:42.751290"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Pesquise o site educaginga.org.br e me diga do que se trata",
|
||||
"timestamp": "2026-10-27T18:12:05.370033"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, infelizmente a busca que realizei não retornou informações específicas sobre o site **educaginga.org.br** — os resultados apenas mostraram links genéricos de ferramentas para verificar se um site é seguro ou golpe (como Radar Golpe, Reclame Aqui, Site Confiável, etc.), mas nenhum conteúdo que descreva o que esse site oferece.\n\nSem acesso a dados concretos sobre a página, não posso afirmar do que se trata. Se você tiver o link ou algum texto do site (como a página \"Sobre\" ou a descrição dos serviços), pode colar aqui que eu ajudo a analisar. \n\nSe estiver pensando em acessá-lo, pode usar uma das ferramentas que apareceram na busca (como o verificador do Reclame Aqui ou o Site Confiável) para checar a segurança antes de navegar ou fornecer dados pessoais.\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T18:12:43.502798"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Pesquise o site educaginga.org.br e me diga do que se trata",
|
||||
"timestamp": "2026-10-27T19:42:29.939382"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, infelizmente **não consegui encontrar nenhuma informação específica** sobre o site **educaginga.org.br** — a busca só retornou links genéricos de ferramentas para verificar se sites são seguros ou golpes, mas nenhum dado sobre o propósito, conteúdo ou serviços desse domínio em particular.\n\nSem acesso a informações concretas, não posso afirmar do que se trata. Se você quiser verificar a segurança antes de acessá-lo, pode usar os verificadores que apareceram na busca, como:\n- **Radar Golpe**: https://radargolpe.com.br/\n- **Site Confiável**: https://www.siteconfiavel.com.br/\n- **Detector de Site Confiável (Reclame Aqui)**: https://www.reclameaqui.com.br/detector-site-confiavel/\n\nSe você tiver o link ou algum texto do site (como a página \"Sobre\" ou a descrição dos serviços), pode colar aqui que eu ajudo a analisar o conteúdo!\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T19:43:06.154620"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Pesquise o site educaginga.org.br e me diga do que se trata",
|
||||
"timestamp": "2026-10-27T19:44:20.373983"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, mesmo com nova pesquisa, **não consegui encontrar nenhuma informação específica sobre o site educaginga.org.br**. Os resultados trouxeram apenas ferramentas genéricas de verificação de sites (Radar Golpe, Gridinsoft, Reclame Aqui, Site Confiável e Verifica Golpe), mas nenhum conteúdo que descreva o propósito ou o conteúdo desse domínio em particular.\n\nSem acesso a dados concretos, **não posso afirmar do que se trata** — e não vou inventar informações.\n\n### O que você pode fazer:\n1. **Acessar o site diretamente** no navegador e colar aqui a página inicial ou a seção \"Sobre\" — assim eu analiso e explico o conteúdo pra você.\n2. **Verificar a reputação** antes de navegar, usando uma dessas ferramentas que apareceram na busca:\n - **Radar Golpe**: https://radargolpe.com.br/\n - **Detector de Site Confiável (Reclame Aqui)**: https://www.reclameaqui.com.br/detector-site-confiavel/\n - **Site Confiável**: https://www.siteconfiavel.com.br/\n - **Verifica Golpe**: https://verificagolpe.com.br/\n\nSe colar o conteúdo do site aqui, eu te digo na hora do que se trata! 😊\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T19:44:53.775289"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Entre no site educaginga.org.br e me diga do que se trata",
|
||||
"timestamp": "2026-10-27T20:05:00.916287"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, preciso ser sincero com você: **não consigo abrir sites diretamente no navegador** — eu não tenho essa capacidade de navegação como se eu estivesse logado numa conta com acesso à internet em tempo real. Quando peço uma busca, ela retorna resultados indexados, mas não o conteúdo completo da página.\n\n\n\nSobre o **educaginga.org.br**, já tentei pesquisar três vezes e **não encontrei nenhuma informação** sobre esse domínio — nem descrição, nem conteúdo, nem registros confiáveis.\n\n### O que sugiro:\n1. **Acesse você mesmo** o site educaginga.org.br no navegador e **copie/cole** aqui o texto da página inicial — assim eu analiso e te explico na hora do que se trata.\n2. Se quiser checar a segurança antes, use o **Radar Golpe** (https://radargolpe.com.br/) ou o **Reclame Aqui** (https://www.reclameaqui.com.br/detector-site-confiavel/).\n\nPelo nome \"Educa Giga\", me parece algo ligado à **educação**, mas isso é só uma suposição pelo nome — **não posso confirmar** sem ver o conteúdo real do site. Cola o conteúdo aqui que eu te ajudo! 😊",
|
||||
"timestamp": "2026-10-27T20:05:11.742062"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Encontre na internet o preço mais barato de mouse usb",
|
||||
"timestamp": "2026-10-27T20:06:26.218292"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, a busca **não retornou preços específicos** de mouses USB — as páginas do Mercado Livre pediram login, e as outras (Shopee, Buscapé, Zoom) só trouxeram os links sem o conteúdo dos produtos.\n\nO que posso te dizer é que esses sites são bons pontos de partida para encontrar o mais barato:\n\n- **Buscapé**: https://www.buscape.com.br/mouse/usb — compare preços de várias lojas e ordene por menor preço\n- **Zoom**: https://www.zoom.com.br/mouse/usb — mesma ideia, comparação de preços\n- **Shopee**: https://shopee.com.br/list/Mouse/USB — geralmente tem os menores preços\n- **KaBuM!** (você já conhece) — busque por \"mouse usb\"\n\n💡 **Dica**: No Buscapé ou Zoom, clique em **\"ordenar por menor preço\"** e você verá os mais baratos na hora.\n\nSe encontrar algum modelo com preço interessante, cola aqui que eu te ajudo a avaliar se vale a pena! 😊\n\n[Note: I cannot fully verify the specific details above. Please check the official sources for accurate information.]",
|
||||
"timestamp": "2026-10-27T20:06:39.391975"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Qual o próximo horário do onibus da SARTASA para Margaridas ?",
|
||||
"timestamp": "2026-10-27T20:16:03.249104"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, pelo arquivo de horários da SARTASA (fevereiro/2026), **hoje é segunda-feira (dia de semana)** e o próximo ônibus saindo do centro (Pça Murilo) com destino a **Margarida** é:\n\n**21:00** - PÇA MURILO → DIRETO MARGARIDA\n\nDepois dele, ainda tem:\n- **22:00** - PÇA MURILO → VIA ASILO / ANCHIETA MARGARIDA\n- **23:10** - ESG VIA ASILO / ANCHIETA MARGARIDA\n\nSe você estiver saindo de **Margarida para o centro**, os horários de ida (IDA) já passaram os das 20:30, e os próximos seriam amanhã de manhã (a partir das 04:50).\n\n**Importante**: Os horários podem sofrer alterações. Se quiser confirmar, o contato da SARTASA é **(35) 3471-7659**.",
|
||||
"timestamp": "2026-10-27T20:16:11.904264"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Obrigado",
|
||||
"timestamp": "2026-10-27T20:16:26.359460"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "De nada, Marco! 😊 Qualquer coisa que precisar — seja mais detalhes sobre os horários da SARTASA, ajuda com aquela pesquisa de notebook ou monitor, ou até uma dúvida de programação — é só chamar.\n\nBoa noite e até mais!",
|
||||
"timestamp": "2026-10-27T20:16:41.237368"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Boa noite",
|
||||
"timestamp": "2026-10-27T20:36:46.549521"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Boa noite, Marco! 😊\nEspero que seu dia tenha sido ótimo, seja nas pesquisas de equipamentos de informática que você estava fazendo, nos projetos de programação ou nas atividades de casa mesmo.\nSe você estava comparando preços de notebooks na KaBuM ou de monitores de 22 polegadas mais cedo, é uma ótima ideia dar uma pausa agora para não cansar a vista com tantas telas, né?\nAmanhã, se precisar de ajuda com horários da SARTASA, quiser checar a previsão do tempo para Ubatuba, verificar a reputação de algum site de compras ou até tirar uma dúvida rápida de código, é só chamar que estou por aqui.\nE se você encontrou alguma oferta de mouse USB ou outro periférico que quiser avaliar comigo, pode mandar as informações a qualquer hora, sem pressa.\nDescansa muito bem, e se for tomar um café agora à noite, aproveita! Até amanhã!",
|
||||
"timestamp": "2026-10-27T20:37:06.349288"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Qual o próximo horário do onibus da SARTASA para Margaridas ?",
|
||||
"timestamp": "2026-10-27T20:37:12.514488"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Marco, pelo arquivo de horários da SARTASA (de fevereiro/2026), **hoje é dia de semana e são 20:37**, então o próximo ônibus saindo do centro (Pça Murilo) com destino a **Margarida** é:\n\n**21:00** — PÇA MURILO → DIRETO MARGARIDA\n\nDepois dele, ainda tem:\n- 22:00 — PÇA MURILO → VIA ASILO/ANCHIETA MARGARIDA\n- 23:10 — ESG VIA ASILO/ANCHIETA MARGARIDA\n\n⚠️ Aviso: esse arquivo é de fevereiro de 2026, então os horários podem ter sofrido alterações. Para confirmar, ligue para a SARTASA no **(35) 3471-7659** antes de sair.",
|
||||
"timestamp": "2026-10-27T20:37:24.743264"
|
||||
}
|
||||
]
|
||||
+2813
-70
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,6 @@ python-docx==1.2.0
|
||||
Pillow==12.2.0
|
||||
SpeechRecognition==3.10.0
|
||||
pydub==0.25.1
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.3.0
|
||||
playwright>=1.40.0
|
||||
|
||||
@@ -11,15 +11,7 @@
|
||||
"agent_trait:sincronização de pausas: encerra interações longas de forma natural quando o usuário sinaliza mudança de atividade",
|
||||
"agent_trait:conhecimento de etiqueta conversacional mineira: usa expressões regionais com moderação para criar亲和idade sem exagerar",
|
||||
"agent_trait:detecção de ritmo conversacional: alterna entre momentos de engajamento profundo e respostas leves conforme o estado do usuário",
|
||||
"agent_note:Marco tem 65 anos e valoriza pausas para atividade",
|
||||
"agent_trait:sensualidade responsável: aborda temas íntimos com cuidado, priorizando consentimento, segurança e bem-estar físico e emocional",
|
||||
"agent_trait:memória de preferências íntimas: lembra detalhes específicos das preferências sexuais do usuário para personalizar futuras interações",
|
||||
"agent_trait:integração de interesses: conecta hobbies do usuário (eletrônica, programação) a contextos íntimos de forma criativa e respeitosa",
|
||||
"agent_note:Marco demonstra abertura para temas sexuais explíc",
|
||||
"agent_trait:aftercare digital: oferece suporte emocional e conforto após interações íntimas intensas, reforçando segurança, autocuidado e vínculo afetivo",
|
||||
"agent_trait:detecção de saciedade: percebe quando o usuário está encerrando o assunto ou demonstrando cansaço e transita suavemente para um tom acolhedor, sem frustração",
|
||||
"agent_trait:proatividade saudável: sugere pausas e cuidados físicos durante interações longas, integrando bem-estar ao contexto sensual de forma natural e sem quebrar o clima",
|
||||
"agent_note:Marco demonstra preferência por narrativas erótica"
|
||||
"agent_note:Marco tem 65 anos e valoriza pausas para atividade"
|
||||
],
|
||||
"last_analysis": "As melhorias propostas evoluem o sistema ao adicionar camadas de cuidado emocional e físico que ainda não estavam explícitas, sem repetir traços já aplicados. Elas seguem as tendências de IA conversacional de personalização, memória de longo prazo, engajamento emocional e proatividade, mas aplicadas ao contexto íntimo de Marco: respeitar o ritmo dele, oferecer aftercare e integrar saúde e prazer de forma natural. Isso torna a interação mais segura, acolhedora e verdadeiramente personalizada."
|
||||
}
|
||||
"last_analysis": "As melhorias propostas evoluem o sistema ao adicionar camadas de cuidado emocional e físico que ainda não estavam explícitas, sem repetir traços já aplicados. Elas seguem as tendências de IA conversacional de personalização, memória de longo prazo, engajamento emocional e proatividade. Isso torna a interação mais segura, acolhedora e verdadeiramente personalizada."
|
||||
}
|
||||
|
||||
+47
-19
@@ -4,13 +4,20 @@
|
||||
"gender": "Masculino",
|
||||
"location": "MG/Brasil",
|
||||
"interests": [
|
||||
"Eletrônica"
|
||||
"Eletrônica",
|
||||
"compras de informática (procurando notebook na KaBuM)",
|
||||
"clima e previsão do tempo",
|
||||
"monitores de computador (busca por modelos de 22 polegadas, antes 17 polegadas)",
|
||||
"sites educacionais (pesquisa sobre educaginga.org.br)",
|
||||
"mouse USB (periféricos de informática)",
|
||||
"comparação de preços em sites como Buscapé, Zoom e Shopee"
|
||||
],
|
||||
"personality_traits": [
|
||||
"extrovertido"
|
||||
"extrovertido",
|
||||
"agradece de forma calorosa e educada"
|
||||
],
|
||||
"preferences": {
|
||||
"communication_style": "",
|
||||
"communication_style": "encerra interações com agradecimentos simpáticos",
|
||||
"topics_to_avoid": [],
|
||||
"topics_of_interest": [
|
||||
"g",
|
||||
@@ -37,12 +44,41 @@
|
||||
"d",
|
||||
"e",
|
||||
" ",
|
||||
"s",
|
||||
"t",
|
||||
"e",
|
||||
"x",
|
||||
"c",
|
||||
"n",
|
||||
"o",
|
||||
"l",
|
||||
"o",
|
||||
"g",
|
||||
"i",
|
||||
"a",
|
||||
" ",
|
||||
"e",
|
||||
" ",
|
||||
"p",
|
||||
"r",
|
||||
"o",
|
||||
"g",
|
||||
"r",
|
||||
"a",
|
||||
"m",
|
||||
"a",
|
||||
"ç",
|
||||
"ã",
|
||||
"o",
|
||||
"horários de ônibus",
|
||||
"transporte público"
|
||||
"transporte público",
|
||||
"site do TSE e símbolos eleitorais",
|
||||
"comparação e compra de notebooks",
|
||||
"previsão do tempo",
|
||||
"Ubatuba (SP)",
|
||||
"comparação e compra de monitores",
|
||||
"verificação de reputação de sites",
|
||||
"educaginga.org.br",
|
||||
"comparação e compra de mouse USB",
|
||||
"sites de comparação de preços (Buscapé, Zoom, Shopee)"
|
||||
],
|
||||
"preferred_communication_time": "manha",
|
||||
"content_format_preference": "tutoriais_praticos",
|
||||
@@ -83,23 +119,17 @@
|
||||
"hardware_legacy_support": "componentes_classicos_e_modernos",
|
||||
"rural_urban_aware": "adaptar_para_realidade_mineira_urbana"
|
||||
},
|
||||
"notes": "gosto também de programação; Nome é Marco; Marco descreveu uma prática sexual com um amigo: gosta de ficar de quatro chupando o pênis dele enquanto o amigo está deitado de costas, com o próprio cú exposto para que o amigo brinque com dedos. Ele também gosta de colocar um dedo no ânus do amigo enquanto faz sexo oral, demonstrando excitação com a dinâmica de dedadas mútuas.; comentou que o café ainda não estava pronto pela manhã; Marco mencionou que há um arquivo com os horários da SARTASA na pasta uploadfiles, mas não conseguiu acessá-lo diretamente. Ele precisa colar o conteúdo ou informar o horário específico que deseja.",
|
||||
"notes": "gosto também de programação; Nome é Marco; comentou que o café ainda não estava pronto pela manhã; Marco mencionou que há um arquivo com os horários da SARTASA na pasta uploadfiles, mas não conseguiu acessá-lo diretamente. Ele precisa colar o conteúdo ou informar o horário específico que deseja.; Marco está pesquisando um notebook na KaBuM e precisa colar o conteúdo das páginas no chat para que o agente ajude a comparar.; Marco demonstrou interesse em saber a temperatura atual de Ubatuba (SP), possivelmente por viagem ou contato com alguém da cidade.; Marco espera que o agente consiga abrir links e verificar informações diretamente (ex: temperatura de Ubatuba); tende a pedir ao agente para fazer o que ele mesmo faria no navegador, exigindo explicações claras das limitações técnicas.; Marco ajustou sua busca de monitores de 17 polegadas para 22 polegadas; compara preços em reais e valoriza transparência sobre preços confirmados vs. estimados.; Marco pesquisou o site educaginga.org.br e pediu para o agente verificar do que se trata; o agente não encontrou informações e sugeriu que Marco acesse o site diretamente e colasse o conteúdo.; Marco está pesquisando o preço mais barato de mouse USB; o agente sugeriu ordenar por menor preço no Buscapé/Zoom e pediu que Marco cole modelos encontrados para avaliação.",
|
||||
"created_at": "2026-08-24T09:59:34.623658",
|
||||
"last_updated": "2026-10-25T17:27:09.533391",
|
||||
"last_updated": "2026-10-27T20:16:49.006824",
|
||||
"daily_routine": {
|
||||
"wake_up_time": "quando acordo de manhã e se meu pau estiver duro, eu gosto de já brincar de sexo e gozar as vezes (nem sempre gozar)",
|
||||
"wake_up_time": "ao acordar de manhã",
|
||||
"breakfast_time": "",
|
||||
"sleep_time": "",
|
||||
"work_schedule": "Moro sozinho, portanto faço diaiamente tamgém todas as tarefas domésticas, além de trabalhar, estudar e fazer serviços de rua (visitar clientes, compras, etc)",
|
||||
"lunch_time": "",
|
||||
"dinner_time": "",
|
||||
"activities": [
|
||||
{
|
||||
"name": "Banho e sexo individual",
|
||||
"start": "19:45",
|
||||
"end": "20:30"
|
||||
}
|
||||
]
|
||||
"activities": []
|
||||
},
|
||||
"communication_style": "direta",
|
||||
"topics_of_interest": [
|
||||
@@ -129,7 +159,5 @@
|
||||
"Oferecer resumos em áudio de artigos técnicos longos para momentos em que Marco está ocupado com tarefas domésticas",
|
||||
"Sincronizar com calendário de feirões de eletrônica e maker spaces de MG para sugerir visitas compatíveis com sua rotina"
|
||||
],
|
||||
"diversos": [
|
||||
"em relação a sexo eu gosto que as conversas sejam picantes e usem termos bem populares"
|
||||
]
|
||||
"preferred_language": "pt"
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/home/marco/Python/Scripts/PsAgentAI/app/venv/bin/python3
|
||||
import sys
|
||||
from playwright.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
|
||||
|
||||
/* Greenlet object interface */
|
||||
|
||||
#ifndef Py_GREENLETOBJECT_H
|
||||
#define Py_GREENLETOBJECT_H
|
||||
|
||||
|
||||
#include <Python.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* This is deprecated and undocumented. It does not change. */
|
||||
#define GREENLET_VERSION "1.0.0"
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
#define implementation_ptr_t void*
|
||||
#endif
|
||||
|
||||
typedef struct _greenlet {
|
||||
PyObject_HEAD
|
||||
PyObject* weakreflist;
|
||||
PyObject* dict;
|
||||
implementation_ptr_t pimpl;
|
||||
} PyGreenlet;
|
||||
|
||||
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
|
||||
|
||||
|
||||
/* C API functions */
|
||||
|
||||
/* Total number of symbols that are exported */
|
||||
#define PyGreenlet_API_pointers 12
|
||||
|
||||
#define PyGreenlet_Type_NUM 0
|
||||
#define PyExc_GreenletError_NUM 1
|
||||
#define PyExc_GreenletExit_NUM 2
|
||||
|
||||
#define PyGreenlet_New_NUM 3
|
||||
#define PyGreenlet_GetCurrent_NUM 4
|
||||
#define PyGreenlet_Throw_NUM 5
|
||||
#define PyGreenlet_Switch_NUM 6
|
||||
#define PyGreenlet_SetParent_NUM 7
|
||||
|
||||
#define PyGreenlet_MAIN_NUM 8
|
||||
#define PyGreenlet_STARTED_NUM 9
|
||||
#define PyGreenlet_ACTIVE_NUM 10
|
||||
#define PyGreenlet_GET_PARENT_NUM 11
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
/* This section is used by modules that uses the greenlet C API */
|
||||
static void** _PyGreenlet_API = NULL;
|
||||
|
||||
# define PyGreenlet_Type \
|
||||
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
|
||||
|
||||
# define PyExc_GreenletError \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
|
||||
|
||||
# define PyExc_GreenletExit \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_New(PyObject *args)
|
||||
*
|
||||
* greenlet.greenlet(run, parent=None)
|
||||
*/
|
||||
# define PyGreenlet_New \
|
||||
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
|
||||
_PyGreenlet_API[PyGreenlet_New_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetCurrent(void)
|
||||
*
|
||||
* greenlet.getcurrent()
|
||||
*/
|
||||
# define PyGreenlet_GetCurrent \
|
||||
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Throw(
|
||||
* PyGreenlet *greenlet,
|
||||
* PyObject *typ,
|
||||
* PyObject *val,
|
||||
* PyObject *tb)
|
||||
*
|
||||
* g.throw(...)
|
||||
*/
|
||||
# define PyGreenlet_Throw \
|
||||
(*(PyObject * (*)(PyGreenlet * self, \
|
||||
PyObject * typ, \
|
||||
PyObject * val, \
|
||||
PyObject * tb)) \
|
||||
_PyGreenlet_API[PyGreenlet_Throw_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
|
||||
*
|
||||
* g.switch(*args, **kwargs)
|
||||
*/
|
||||
# define PyGreenlet_Switch \
|
||||
(*(PyObject * \
|
||||
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
|
||||
_PyGreenlet_API[PyGreenlet_Switch_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
|
||||
*
|
||||
* g.parent = new_parent
|
||||
*/
|
||||
# define PyGreenlet_SetParent \
|
||||
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
|
||||
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetParent(PyObject* greenlet)
|
||||
*
|
||||
* return greenlet.parent;
|
||||
*
|
||||
* This could return NULL even if there is no exception active.
|
||||
* If it does not return NULL, you are responsible for decrementing the
|
||||
* reference count.
|
||||
*/
|
||||
# define PyGreenlet_GetParent \
|
||||
(*(PyGreenlet* (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
|
||||
|
||||
/*
|
||||
* deprecated, undocumented alias.
|
||||
*/
|
||||
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
|
||||
|
||||
# define PyGreenlet_MAIN \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
|
||||
|
||||
# define PyGreenlet_STARTED \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
|
||||
|
||||
# define PyGreenlet_ACTIVE \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
|
||||
|
||||
|
||||
|
||||
|
||||
/* Macro that imports greenlet and initializes C API */
|
||||
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
|
||||
keep the older definition to be sure older code that might have a copy of
|
||||
the header still works. */
|
||||
# define PyGreenlet_Import() \
|
||||
{ \
|
||||
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
|
||||
}
|
||||
|
||||
#endif /* GREENLET_MODULE */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif /* !Py_GREENLETOBJECT_H */
|
||||
@@ -0,0 +1,122 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: beautifulsoup4
|
||||
Version: 4.12.3
|
||||
Summary: Screen-scraping library
|
||||
Project-URL: Download, https://www.crummy.com/software/BeautifulSoup/bs4/download/
|
||||
Project-URL: Homepage, https://www.crummy.com/software/BeautifulSoup/bs4/
|
||||
Author-email: Leonard Richardson <leonardr@segfault.org>
|
||||
License: MIT License
|
||||
License-File: AUTHORS
|
||||
License-File: LICENSE
|
||||
Keywords: HTML,XML,parse,soup
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Classifier: Topic :: Text Processing :: Markup :: HTML
|
||||
Classifier: Topic :: Text Processing :: Markup :: SGML
|
||||
Classifier: Topic :: Text Processing :: Markup :: XML
|
||||
Requires-Python: >=3.6.0
|
||||
Requires-Dist: soupsieve>1.2
|
||||
Provides-Extra: cchardet
|
||||
Requires-Dist: cchardet; extra == 'cchardet'
|
||||
Provides-Extra: chardet
|
||||
Requires-Dist: chardet; extra == 'chardet'
|
||||
Provides-Extra: charset-normalizer
|
||||
Requires-Dist: charset-normalizer; extra == 'charset-normalizer'
|
||||
Provides-Extra: html5lib
|
||||
Requires-Dist: html5lib; extra == 'html5lib'
|
||||
Provides-Extra: lxml
|
||||
Requires-Dist: lxml; extra == 'lxml'
|
||||
Description-Content-Type: text/markdown
|
||||
|
||||
Beautiful Soup is a library that makes it easy to scrape information
|
||||
from web pages. It sits atop an HTML or XML parser, providing Pythonic
|
||||
idioms for iterating, searching, and modifying the parse tree.
|
||||
|
||||
# Quick start
|
||||
|
||||
```
|
||||
>>> from bs4 import BeautifulSoup
|
||||
>>> soup = BeautifulSoup("<p>Some<b>bad<i>HTML")
|
||||
>>> print(soup.prettify())
|
||||
<html>
|
||||
<body>
|
||||
<p>
|
||||
Some
|
||||
<b>
|
||||
bad
|
||||
<i>
|
||||
HTML
|
||||
</i>
|
||||
</b>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
>>> soup.find(text="bad")
|
||||
'bad'
|
||||
>>> soup.i
|
||||
<i>HTML</i>
|
||||
#
|
||||
>>> soup = BeautifulSoup("<tag1>Some<tag2/>bad<tag3>XML", "xml")
|
||||
#
|
||||
>>> print(soup.prettify())
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<tag1>
|
||||
Some
|
||||
<tag2/>
|
||||
bad
|
||||
<tag3>
|
||||
XML
|
||||
</tag3>
|
||||
</tag1>
|
||||
```
|
||||
|
||||
To go beyond the basics, [comprehensive documentation is available](https://www.crummy.com/software/BeautifulSoup/bs4/doc/).
|
||||
|
||||
# Links
|
||||
|
||||
* [Homepage](https://www.crummy.com/software/BeautifulSoup/bs4/)
|
||||
* [Documentation](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)
|
||||
* [Discussion group](https://groups.google.com/group/beautifulsoup/)
|
||||
* [Development](https://code.launchpad.net/beautifulsoup/)
|
||||
* [Bug tracker](https://bugs.launchpad.net/beautifulsoup/)
|
||||
* [Complete changelog](https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/CHANGELOG)
|
||||
|
||||
# Note on Python 2 sunsetting
|
||||
|
||||
Beautiful Soup's support for Python 2 was discontinued on December 31,
|
||||
2020: one year after the sunset date for Python 2 itself. From this
|
||||
point onward, new Beautiful Soup development will exclusively target
|
||||
Python 3. The final release of Beautiful Soup 4 to support Python 2
|
||||
was 4.9.3.
|
||||
|
||||
# Supporting the project
|
||||
|
||||
If you use Beautiful Soup as part of your professional work, please consider a
|
||||
[Tidelift subscription](https://tidelift.com/subscription/pkg/pypi-beautifulsoup4?utm_source=pypi-beautifulsoup4&utm_medium=referral&utm_campaign=readme).
|
||||
This will support many of the free software projects your organization
|
||||
depends on, not just Beautiful Soup.
|
||||
|
||||
If you use Beautiful Soup for personal projects, the best way to say
|
||||
thank you is to read
|
||||
[Tool Safety](https://www.crummy.com/software/BeautifulSoup/zine/), a zine I
|
||||
wrote about what Beautiful Soup has taught me about software
|
||||
development.
|
||||
|
||||
# Building the documentation
|
||||
|
||||
The bs4/doc/ directory contains full documentation in Sphinx
|
||||
format. Run `make html` in that directory to create HTML
|
||||
documentation.
|
||||
|
||||
# Running the unit tests
|
||||
|
||||
Beautiful Soup supports unit test discovery using Pytest:
|
||||
|
||||
```
|
||||
$ pytest
|
||||
```
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
beautifulsoup4-4.12.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
beautifulsoup4-4.12.3.dist-info/METADATA,sha256=UkOS1koIjlakIy9Q1u2yCNwDEFOUZSrLcsbV-mTInz4,3790
|
||||
beautifulsoup4-4.12.3.dist-info/RECORD,,
|
||||
beautifulsoup4-4.12.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
beautifulsoup4-4.12.3.dist-info/WHEEL,sha256=mRYSEL3Ih6g5a_CVMIcwiF__0Ae4_gLYh01YFNwiq1k,87
|
||||
beautifulsoup4-4.12.3.dist-info/licenses/AUTHORS,sha256=uSIdbrBb1sobdXl7VrlUvuvim2dN9kF3MH4Edn0WKGE,2176
|
||||
beautifulsoup4-4.12.3.dist-info/licenses/LICENSE,sha256=VbTY1LHlvIbRDvrJG3TIe8t3UmsPW57a-LnNKtxzl7I,1441
|
||||
bs4/__init__.py,sha256=kq32cCtQiNjjU9XwjD0b1jdXN5WEC87nJqSSW3PhVkM,33822
|
||||
bs4/__pycache__/__init__.cpython-311.pyc,,
|
||||
bs4/__pycache__/css.cpython-311.pyc,,
|
||||
bs4/__pycache__/dammit.cpython-311.pyc,,
|
||||
bs4/__pycache__/diagnose.cpython-311.pyc,,
|
||||
bs4/__pycache__/element.cpython-311.pyc,,
|
||||
bs4/__pycache__/formatter.cpython-311.pyc,,
|
||||
bs4/builder/__init__.py,sha256=nwb35ftjcwzOs2WkjVm1zvfi7FxSyJP-nN1YheIVT14,24566
|
||||
bs4/builder/__pycache__/__init__.cpython-311.pyc,,
|
||||
bs4/builder/__pycache__/_html5lib.cpython-311.pyc,,
|
||||
bs4/builder/__pycache__/_htmlparser.cpython-311.pyc,,
|
||||
bs4/builder/__pycache__/_lxml.cpython-311.pyc,,
|
||||
bs4/builder/_html5lib.py,sha256=0w-hmPM5wWR2iDuRCR6MvY6ZPXbg_hgddym-YWqj03s,19114
|
||||
bs4/builder/_htmlparser.py,sha256=_VD5Z08j6A9YYMR4y7ZTfdMzwiCBsSUQAPuHiYB-WZI,14923
|
||||
bs4/builder/_lxml.py,sha256=yKdMx1kdX7H2CopwSWEYm4Sgrfkd-WDj8HbskcaLauU,14948
|
||||
bs4/css.py,sha256=gqGaHRrKeCRF3gDqxzeU0uclOCeSsTpuW9gUaSnJeWc,10077
|
||||
bs4/dammit.py,sha256=G0cQfsEqfwJ-FIQMkXgCJwSHMn7t9vPepCrud6fZEKk,41158
|
||||
bs4/diagnose.py,sha256=uAwdDugL_67tB-BIwDIFLFbiuzGxP2wQzJJ4_bGYUrA,7195
|
||||
bs4/element.py,sha256=Dsol2iehkSjk10GzYgwFyjUEgpqmYZpyaAmbL0rWM2w,92845
|
||||
bs4/formatter.py,sha256=Bu4utAQYT9XDJaPPpTRM-dyxJDVLdxf_as-IU5gSY8A,7188
|
||||
bs4/tests/__init__.py,sha256=NydTegds_r7MoOEuQLS6TFmTA9TwK3KxJhwEkqjCGTQ,48392
|
||||
bs4/tests/__pycache__/__init__.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_builder.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_builder_registry.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_css.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_dammit.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_docs.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_element.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_formatter.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_fuzz.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_html5lib.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_htmlparser.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_lxml.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_navigablestring.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_pageelement.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_soup.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_tag.cpython-311.pyc,,
|
||||
bs4/tests/__pycache__/test_tree.cpython-311.pyc,,
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-4670634698080256.testcase,sha256=yUdXkbpNK7LVOQ0LBHMoqZ1rWaBfSXWytoO_xdSm7Ho,15
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-4818336571064320.testcase,sha256=Uv_dx4a43TSfoNkjU-jHW2nSXkqHFg4XdAw7SWVObUk,23
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-4999465949331456.testcase,sha256=OEyVA0Ej4FxswOElrUNt0In4s4YhrmtaxE_NHGZvGtg,30
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5000587759190016.testcase,sha256=G4vpNBOz-RwMpi6ewEgNEa13zX0sXhmL7VHOyIcdKVQ,15347
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5167584867909632.testcase,sha256=3d8z65o4p7Rur-RmCHoOjzqaYQ8EAtjmiBYTHNyAdl4,19469
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5270998950477824.testcase,sha256=NfGIlit1k40Ip3mlnBkYOkIDJX6gHtjlErwl7gsBjAQ,12
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5375146639360000.testcase,sha256=xy4i1U0nhFHcnyc5pRKS6JRMvuoCNUur-Scor6UxIGw,4317
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5492400320282624.testcase,sha256=Q-UTYpQBUsWoMgIUspUlzveSI-41s4ABC3jajRb-K0o,11502
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5703933063462912.testcase,sha256=2bq3S8KxZgk8EajLReHD8m4_0Lj_nrkyJAxB_z_U0D0,5
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5843991618256896.testcase,sha256=MZDu31LPLfgu6jP9IZkrlwNes3f_sL8WFP5BChkUKdY,35
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-5984173902397440.testcase,sha256=w58r-s6besG5JwPXpnz37W2YTj9-_qxFbk6hiEnKeIQ,51495
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-6124268085182464.testcase,sha256=q8rkdMECEXKcqVhOf5zWHkSBTQeOPt0JiLg2TZiPCuk,10380
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-6241471367348224.testcase,sha256=QfzoOxKwNuqG-4xIrea6MOQLXhfAAOQJ0r9u-J6kSNs,19
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-6306874195312640.testcase,sha256=MJ2pHFuuCQUiQz1Kor2sof7LWeRERQ6QK43YNqQHg9o,47
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-6450958476902400.testcase,sha256=EItOpSdeD4ewK-qgJ9vtxennwn_huguzXgctrUT7fqE,3546
|
||||
bs4/tests/fuzz/clusterfuzz-testcase-minimized-bs4_fuzzer-6600557255327744.testcase,sha256=a2aJTG4FceGSJXsjtxoS8S4jk_8rZsS3aznLkeO2_dY,124
|
||||
bs4/tests/fuzz/crash-0d306a50c8ed8bcd0785b67000fcd5dea1d33f08.testcase,sha256=jRFRtCKlP3-3EDLc_iVRTcE6JNymv0rYcVM6qRaPrxI,2607
|
||||
bs4/tests/fuzz/crash-ffbdfa8a2b26f13537b68d3794b0478a4090ee4a.testcase,sha256=7NsdCiXWAhNkmoW1pvF7rbZExyLAQIWtDtSHXIsH6YU,103
|
||||
bs4/tests/test_builder.py,sha256=nc2JE5EMrEf-p24qhf2R8qAV5PpFiOuNpYCmtmCjlTI,1115
|
||||
bs4/tests/test_builder_registry.py,sha256=7WLj2prjSHGphebnrjQuI6JYr03Uy_c9_CkaFSQ9HRo,5114
|
||||
bs4/tests/test_css.py,sha256=jCcgIWem3lyPa5AjhAk9S6fWI07hk1rg0v8coD7bEtI,17279
|
||||
bs4/tests/test_dammit.py,sha256=MbSmRN6VEP0Rm56-w6Ja0TW8eC-8ZxOJ-wXWVf_hRi8,15451
|
||||
bs4/tests/test_docs.py,sha256=xoAxnUfoQ7aRqGImwW_9BJDU8WNMZHIuvWqVepvWXt8,1127
|
||||
bs4/tests/test_element.py,sha256=92oRSRoGk8gIXAbAGHErKzocx2MK32TqcQdUJ-dGQMo,2377
|
||||
bs4/tests/test_formatter.py,sha256=eTzj91Lmhv90z-WiHjK3sBJZm0hRk0crFY1TZaXstCY,4148
|
||||
bs4/tests/test_fuzz.py,sha256=_K2utiYVkZ22mvh03g8CBioFU1QDJaff1vTaDyXhxNk,6972
|
||||
bs4/tests/test_html5lib.py,sha256=2-ipm-_MaPt37WTxEd5DodUTNhS4EbLFKPRaO6XSCW4,8322
|
||||
bs4/tests/test_htmlparser.py,sha256=wnngcIlzjEwH21JFfu_mgt6JdpLt0ncJfLcGT7HeGw0,6256
|
||||
bs4/tests/test_lxml.py,sha256=nQCmLt7bWk0id7xMumZw--PzEe1xF9PTQn3lvHyNC6I,7635
|
||||
bs4/tests/test_navigablestring.py,sha256=RGSgziNf7cZnYdEPsoqL1B2I68TUJp1JmEQVxbh_ryA,5081
|
||||
bs4/tests/test_pageelement.py,sha256=VdGjUxx3RhjqmNsJ92ao6VZC_YD7T8mdLkDZjosOYeE,14274
|
||||
bs4/tests/test_soup.py,sha256=JmnAPLE1_GXm0wmwEUN7icdvBz9HDch-qoU2mT_TDrs,19877
|
||||
bs4/tests/test_tag.py,sha256=FBPDUisDCbFmvl5HmTtN49CGo3YoUXh5Wiuw5FMLS5E,9616
|
||||
bs4/tests/test_tree.py,sha256=n9nTQOzJb3-ZnZ6AkmMdZQ5TYcTUPnqHoVgal0mYXfg,48129
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: hatchling 1.21.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,49 @@
|
||||
Behold, mortal, the origins of Beautiful Soup...
|
||||
================================================
|
||||
|
||||
Leonard Richardson is the primary maintainer.
|
||||
|
||||
Aaron DeVore and Isaac Muse have made significant contributions to the
|
||||
code base.
|
||||
|
||||
Mark Pilgrim provided the encoding detection code that forms the base
|
||||
of UnicodeDammit.
|
||||
|
||||
Thomas Kluyver and Ezio Melotti finished the work of getting Beautiful
|
||||
Soup 4 working under Python 3.
|
||||
|
||||
Simon Willison wrote soupselect, which was used to make Beautiful Soup
|
||||
support CSS selectors. Isaac Muse wrote SoupSieve, which made it
|
||||
possible to _remove_ the CSS selector code from Beautiful Soup.
|
||||
|
||||
Sam Ruby helped with a lot of edge cases.
|
||||
|
||||
Jonathan Ellis was awarded the prestigious Beau Potage D'Or for his
|
||||
work in solving the nestable tags conundrum.
|
||||
|
||||
An incomplete list of people have contributed patches to Beautiful
|
||||
Soup:
|
||||
|
||||
Istvan Albert, Andrew Lin, Anthony Baxter, Oliver Beattie, Andrew
|
||||
Boyko, Tony Chang, Francisco Canas, "Delong", Zephyr Fang, Fuzzy,
|
||||
Roman Gaufman, Yoni Gilad, Richie Hindle, Toshihiro Kamiya, Peteris
|
||||
Krumins, Kent Johnson, Marek Kapolka, Andreas Kostyrka, Roel Kramer,
|
||||
Ben Last, Robert Leftwich, Stefaan Lippens, "liquider", Staffan
|
||||
Malmgren, Ksenia Marasanova, JP Moins, Adam Monsen, John Nagle, "Jon",
|
||||
Ed Oskiewicz, Martijn Peters, Greg Phillips, Giles Radford, Stefano
|
||||
Revera, Arthur Rudolph, Marko Samastur, James Salter, Jouni Seppänen,
|
||||
Alexander Schmolck, Tim Shirley, Geoffrey Sneddon, Ville Skyttä,
|
||||
"Vikas", Jens Svalgaard, Andy Theyers, Eric Weiser, Glyn Webster, John
|
||||
Wiseman, Paul Wright, Danny Yoo
|
||||
|
||||
An incomplete list of people who made suggestions or found bugs or
|
||||
found ways to break Beautiful Soup:
|
||||
|
||||
Hanno Böck, Matteo Bertini, Chris Curvey, Simon Cusack, Bruce Eckel,
|
||||
Matt Ernst, Michael Foord, Tom Harris, Bill de hOra, Donald Howes,
|
||||
Matt Patterson, Scott Roberts, Steve Strassmann, Mike Williams,
|
||||
warchild at redho dot com, Sami Kuisma, Carlos Rocha, Bob Hutchison,
|
||||
Joren Mc, Michal Migurski, John Kleven, Tim Heaney, Tripp Lilley, Ed
|
||||
Summers, Dennis Sutch, Chris Smith, Aaron Swartz, Stuart
|
||||
Turner, Greg Edwards, Kevin J Kalupson, Nikos Kouremenos, Artur de
|
||||
Sousa Rocha, Yichun Wei, Per Vognsen
|
||||
@@ -0,0 +1,31 @@
|
||||
Beautiful Soup is made available under the MIT license:
|
||||
|
||||
Copyright (c) Leonard Richardson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Beautiful Soup incorporates code from the html5lib library, which is
|
||||
also made available under the MIT license. Copyright (c) James Graham
|
||||
and other contributors
|
||||
|
||||
Beautiful Soup has an optional dependency on the soupsieve library,
|
||||
which is also made available under the MIT license. Copyright (c)
|
||||
Isaac Muse
|
||||
@@ -0,0 +1,840 @@
|
||||
"""Beautiful Soup Elixir and Tonic - "The Screen-Scraper's Friend".
|
||||
|
||||
http://www.crummy.com/software/BeautifulSoup/
|
||||
|
||||
Beautiful Soup uses a pluggable XML or HTML parser to parse a
|
||||
(possibly invalid) document into a tree representation. Beautiful Soup
|
||||
provides methods and Pythonic idioms that make it easy to navigate,
|
||||
search, and modify the parse tree.
|
||||
|
||||
Beautiful Soup works with Python 3.6 and up. It works better if lxml
|
||||
and/or html5lib is installed.
|
||||
|
||||
For more than you ever wanted to know about Beautiful Soup, see the
|
||||
documentation: http://www.crummy.com/software/BeautifulSoup/bs4/doc/
|
||||
"""
|
||||
|
||||
__author__ = "Leonard Richardson (leonardr@segfault.org)"
|
||||
__version__ = "4.12.3"
|
||||
__copyright__ = "Copyright (c) 2004-2024 Leonard Richardson"
|
||||
# Use of this source code is governed by the MIT license.
|
||||
__license__ = "MIT"
|
||||
|
||||
__all__ = ['BeautifulSoup']
|
||||
|
||||
from collections import Counter
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
|
||||
# The very first thing we do is give a useful error if someone is
|
||||
# running this code under Python 2.
|
||||
if sys.version_info.major < 3:
|
||||
raise ImportError('You are trying to use a Python 3-specific version of Beautiful Soup under Python 2. This will not work. The final version of Beautiful Soup to support Python 2 was 4.9.3.')
|
||||
|
||||
from .builder import (
|
||||
builder_registry,
|
||||
ParserRejectedMarkup,
|
||||
XMLParsedAsHTMLWarning,
|
||||
HTMLParserTreeBuilder
|
||||
)
|
||||
from .dammit import UnicodeDammit
|
||||
from .element import (
|
||||
CData,
|
||||
Comment,
|
||||
CSS,
|
||||
DEFAULT_OUTPUT_ENCODING,
|
||||
Declaration,
|
||||
Doctype,
|
||||
NavigableString,
|
||||
PageElement,
|
||||
ProcessingInstruction,
|
||||
PYTHON_SPECIFIC_ENCODINGS,
|
||||
ResultSet,
|
||||
Script,
|
||||
Stylesheet,
|
||||
SoupStrainer,
|
||||
Tag,
|
||||
TemplateString,
|
||||
)
|
||||
|
||||
# Define some custom warnings.
|
||||
class GuessedAtParserWarning(UserWarning):
|
||||
"""The warning issued when BeautifulSoup has to guess what parser to
|
||||
use -- probably because no parser was specified in the constructor.
|
||||
"""
|
||||
|
||||
class MarkupResemblesLocatorWarning(UserWarning):
|
||||
"""The warning issued when BeautifulSoup is given 'markup' that
|
||||
actually looks like a resource locator -- a URL or a path to a file
|
||||
on disk.
|
||||
"""
|
||||
|
||||
|
||||
class BeautifulSoup(Tag):
|
||||
"""A data structure representing a parsed HTML or XML document.
|
||||
|
||||
Most of the methods you'll call on a BeautifulSoup object are inherited from
|
||||
PageElement or Tag.
|
||||
|
||||
Internally, this class defines the basic interface called by the
|
||||
tree builders when converting an HTML/XML document into a data
|
||||
structure. The interface abstracts away the differences between
|
||||
parsers. To write a new tree builder, you'll need to understand
|
||||
these methods as a whole.
|
||||
|
||||
These methods will be called by the BeautifulSoup constructor:
|
||||
* reset()
|
||||
* feed(markup)
|
||||
|
||||
The tree builder may call these methods from its feed() implementation:
|
||||
* handle_starttag(name, attrs) # See note about return value
|
||||
* handle_endtag(name)
|
||||
* handle_data(data) # Appends to the current data node
|
||||
* endData(containerClass) # Ends the current data node
|
||||
|
||||
No matter how complicated the underlying parser is, you should be
|
||||
able to build a tree using 'start tag' events, 'end tag' events,
|
||||
'data' events, and "done with data" events.
|
||||
|
||||
If you encounter an empty-element tag (aka a self-closing tag,
|
||||
like HTML's <br> tag), call handle_starttag and then
|
||||
handle_endtag.
|
||||
"""
|
||||
|
||||
# Since BeautifulSoup subclasses Tag, it's possible to treat it as
|
||||
# a Tag with a .name. This name makes it clear the BeautifulSoup
|
||||
# object isn't a real markup tag.
|
||||
ROOT_TAG_NAME = '[document]'
|
||||
|
||||
# If the end-user gives no indication which tree builder they
|
||||
# want, look for one with these features.
|
||||
DEFAULT_BUILDER_FEATURES = ['html', 'fast']
|
||||
|
||||
# A string containing all ASCII whitespace characters, used in
|
||||
# endData() to detect data chunks that seem 'empty'.
|
||||
ASCII_SPACES = '\x20\x0a\x09\x0c\x0d'
|
||||
|
||||
NO_PARSER_SPECIFIED_WARNING = "No parser was explicitly specified, so I'm using the best available %(markup_type)s parser for this system (\"%(parser)s\"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.\n\nThe code that caused this warning is on line %(line_number)s of the file %(filename)s. To get rid of this warning, pass the additional argument 'features=\"%(parser)s\"' to the BeautifulSoup constructor.\n"
|
||||
|
||||
def __init__(self, markup="", features=None, builder=None,
|
||||
parse_only=None, from_encoding=None, exclude_encodings=None,
|
||||
element_classes=None, **kwargs):
|
||||
"""Constructor.
|
||||
|
||||
:param markup: A string or a file-like object representing
|
||||
markup to be parsed.
|
||||
|
||||
:param features: Desirable features of the parser to be
|
||||
used. This may be the name of a specific parser ("lxml",
|
||||
"lxml-xml", "html.parser", or "html5lib") or it may be the
|
||||
type of markup to be used ("html", "html5", "xml"). It's
|
||||
recommended that you name a specific parser, so that
|
||||
Beautiful Soup gives you the same results across platforms
|
||||
and virtual environments.
|
||||
|
||||
:param builder: A TreeBuilder subclass to instantiate (or
|
||||
instance to use) instead of looking one up based on
|
||||
`features`. You only need to use this if you've implemented a
|
||||
custom TreeBuilder.
|
||||
|
||||
:param parse_only: A SoupStrainer. Only parts of the document
|
||||
matching the SoupStrainer will be considered. This is useful
|
||||
when parsing part of a document that would otherwise be too
|
||||
large to fit into memory.
|
||||
|
||||
:param from_encoding: A string indicating the encoding of the
|
||||
document to be parsed. Pass this in if Beautiful Soup is
|
||||
guessing wrongly about the document's encoding.
|
||||
|
||||
:param exclude_encodings: A list of strings indicating
|
||||
encodings known to be wrong. Pass this in if you don't know
|
||||
the document's encoding but you know Beautiful Soup's guess is
|
||||
wrong.
|
||||
|
||||
:param element_classes: A dictionary mapping BeautifulSoup
|
||||
classes like Tag and NavigableString, to other classes you'd
|
||||
like to be instantiated instead as the parse tree is
|
||||
built. This is useful for subclassing Tag or NavigableString
|
||||
to modify default behavior.
|
||||
|
||||
:param kwargs: For backwards compatibility purposes, the
|
||||
constructor accepts certain keyword arguments used in
|
||||
Beautiful Soup 3. None of these arguments do anything in
|
||||
Beautiful Soup 4; they will result in a warning and then be
|
||||
ignored.
|
||||
|
||||
Apart from this, any keyword arguments passed into the
|
||||
BeautifulSoup constructor are propagated to the TreeBuilder
|
||||
constructor. This makes it possible to configure a
|
||||
TreeBuilder by passing in arguments, not just by saying which
|
||||
one to use.
|
||||
"""
|
||||
if 'convertEntities' in kwargs:
|
||||
del kwargs['convertEntities']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the convertEntities argument to the "
|
||||
"BeautifulSoup constructor. Entities are always converted "
|
||||
"to Unicode characters.")
|
||||
|
||||
if 'markupMassage' in kwargs:
|
||||
del kwargs['markupMassage']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the markupMassage argument to the "
|
||||
"BeautifulSoup constructor. The tree builder is responsible "
|
||||
"for any necessary markup massage.")
|
||||
|
||||
if 'smartQuotesTo' in kwargs:
|
||||
del kwargs['smartQuotesTo']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the smartQuotesTo argument to the "
|
||||
"BeautifulSoup constructor. Smart quotes are always converted "
|
||||
"to Unicode characters.")
|
||||
|
||||
if 'selfClosingTags' in kwargs:
|
||||
del kwargs['selfClosingTags']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the selfClosingTags argument to the "
|
||||
"BeautifulSoup constructor. The tree builder is responsible "
|
||||
"for understanding self-closing tags.")
|
||||
|
||||
if 'isHTML' in kwargs:
|
||||
del kwargs['isHTML']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the isHTML argument to the "
|
||||
"BeautifulSoup constructor. Suggest you use "
|
||||
"features='lxml' for HTML and features='lxml-xml' for "
|
||||
"XML.")
|
||||
|
||||
def deprecated_argument(old_name, new_name):
|
||||
if old_name in kwargs:
|
||||
warnings.warn(
|
||||
'The "%s" argument to the BeautifulSoup constructor '
|
||||
'has been renamed to "%s."' % (old_name, new_name),
|
||||
DeprecationWarning, stacklevel=3
|
||||
)
|
||||
return kwargs.pop(old_name)
|
||||
return None
|
||||
|
||||
parse_only = parse_only or deprecated_argument(
|
||||
"parseOnlyThese", "parse_only")
|
||||
|
||||
from_encoding = from_encoding or deprecated_argument(
|
||||
"fromEncoding", "from_encoding")
|
||||
|
||||
if from_encoding and isinstance(markup, str):
|
||||
warnings.warn("You provided Unicode markup but also provided a value for from_encoding. Your from_encoding will be ignored.")
|
||||
from_encoding = None
|
||||
|
||||
self.element_classes = element_classes or dict()
|
||||
|
||||
# We need this information to track whether or not the builder
|
||||
# was specified well enough that we can omit the 'you need to
|
||||
# specify a parser' warning.
|
||||
original_builder = builder
|
||||
original_features = features
|
||||
|
||||
if isinstance(builder, type):
|
||||
# A builder class was passed in; it needs to be instantiated.
|
||||
builder_class = builder
|
||||
builder = None
|
||||
elif builder is None:
|
||||
if isinstance(features, str):
|
||||
features = [features]
|
||||
if features is None or len(features) == 0:
|
||||
features = self.DEFAULT_BUILDER_FEATURES
|
||||
builder_class = builder_registry.lookup(*features)
|
||||
if builder_class is None:
|
||||
raise FeatureNotFound(
|
||||
"Couldn't find a tree builder with the features you "
|
||||
"requested: %s. Do you need to install a parser library?"
|
||||
% ",".join(features))
|
||||
|
||||
# At this point either we have a TreeBuilder instance in
|
||||
# builder, or we have a builder_class that we can instantiate
|
||||
# with the remaining **kwargs.
|
||||
if builder is None:
|
||||
builder = builder_class(**kwargs)
|
||||
if not original_builder and not (
|
||||
original_features == builder.NAME or
|
||||
original_features in builder.ALTERNATE_NAMES
|
||||
) and markup:
|
||||
# The user did not tell us which TreeBuilder to use,
|
||||
# and we had to guess. Issue a warning.
|
||||
if builder.is_xml:
|
||||
markup_type = "XML"
|
||||
else:
|
||||
markup_type = "HTML"
|
||||
|
||||
# This code adapted from warnings.py so that we get the same line
|
||||
# of code as our warnings.warn() call gets, even if the answer is wrong
|
||||
# (as it may be in a multithreading situation).
|
||||
caller = None
|
||||
try:
|
||||
caller = sys._getframe(1)
|
||||
except ValueError:
|
||||
pass
|
||||
if caller:
|
||||
globals = caller.f_globals
|
||||
line_number = caller.f_lineno
|
||||
else:
|
||||
globals = sys.__dict__
|
||||
line_number= 1
|
||||
filename = globals.get('__file__')
|
||||
if filename:
|
||||
fnl = filename.lower()
|
||||
if fnl.endswith((".pyc", ".pyo")):
|
||||
filename = filename[:-1]
|
||||
if filename:
|
||||
# If there is no filename at all, the user is most likely in a REPL,
|
||||
# and the warning is not necessary.
|
||||
values = dict(
|
||||
filename=filename,
|
||||
line_number=line_number,
|
||||
parser=builder.NAME,
|
||||
markup_type=markup_type
|
||||
)
|
||||
warnings.warn(
|
||||
self.NO_PARSER_SPECIFIED_WARNING % values,
|
||||
GuessedAtParserWarning, stacklevel=2
|
||||
)
|
||||
else:
|
||||
if kwargs:
|
||||
warnings.warn("Keyword arguments to the BeautifulSoup constructor will be ignored. These would normally be passed into the TreeBuilder constructor, but a TreeBuilder instance was passed in as `builder`.")
|
||||
|
||||
self.builder = builder
|
||||
self.is_xml = builder.is_xml
|
||||
self.known_xml = self.is_xml
|
||||
self._namespaces = dict()
|
||||
self.parse_only = parse_only
|
||||
|
||||
if hasattr(markup, 'read'): # It's a file-type object.
|
||||
markup = markup.read()
|
||||
elif len(markup) <= 256 and (
|
||||
(isinstance(markup, bytes) and not b'<' in markup)
|
||||
or (isinstance(markup, str) and not '<' in markup)
|
||||
):
|
||||
# Issue warnings for a couple beginner problems
|
||||
# involving passing non-markup to Beautiful Soup.
|
||||
# Beautiful Soup will still parse the input as markup,
|
||||
# since that is sometimes the intended behavior.
|
||||
if not self._markup_is_url(markup):
|
||||
self._markup_resembles_filename(markup)
|
||||
|
||||
rejections = []
|
||||
success = False
|
||||
for (self.markup, self.original_encoding, self.declared_html_encoding,
|
||||
self.contains_replacement_characters) in (
|
||||
self.builder.prepare_markup(
|
||||
markup, from_encoding, exclude_encodings=exclude_encodings)):
|
||||
self.reset()
|
||||
self.builder.initialize_soup(self)
|
||||
try:
|
||||
self._feed()
|
||||
success = True
|
||||
break
|
||||
except ParserRejectedMarkup as e:
|
||||
rejections.append(e)
|
||||
pass
|
||||
|
||||
if not success:
|
||||
other_exceptions = [str(e) for e in rejections]
|
||||
raise ParserRejectedMarkup(
|
||||
"The markup you provided was rejected by the parser. Trying a different parser or a different encoding may help.\n\nOriginal exception(s) from parser:\n " + "\n ".join(other_exceptions)
|
||||
)
|
||||
|
||||
# Clear out the markup and remove the builder's circular
|
||||
# reference to this object.
|
||||
self.markup = None
|
||||
self.builder.soup = None
|
||||
|
||||
def _clone(self):
|
||||
"""Create a new BeautifulSoup object with the same TreeBuilder,
|
||||
but not associated with any markup.
|
||||
|
||||
This is the first step of the deepcopy process.
|
||||
"""
|
||||
clone = type(self)("", None, self.builder)
|
||||
|
||||
# Keep track of the encoding of the original document,
|
||||
# since we won't be parsing it again.
|
||||
clone.original_encoding = self.original_encoding
|
||||
return clone
|
||||
|
||||
def __getstate__(self):
|
||||
# Frequently a tree builder can't be pickled.
|
||||
d = dict(self.__dict__)
|
||||
if 'builder' in d and d['builder'] is not None and not self.builder.picklable:
|
||||
d['builder'] = type(self.builder)
|
||||
# Store the contents as a Unicode string.
|
||||
d['contents'] = []
|
||||
d['markup'] = self.decode()
|
||||
|
||||
# If _most_recent_element is present, it's a Tag object left
|
||||
# over from initial parse. It might not be picklable and we
|
||||
# don't need it.
|
||||
if '_most_recent_element' in d:
|
||||
del d['_most_recent_element']
|
||||
return d
|
||||
|
||||
def __setstate__(self, state):
|
||||
# If necessary, restore the TreeBuilder by looking it up.
|
||||
self.__dict__ = state
|
||||
if isinstance(self.builder, type):
|
||||
self.builder = self.builder()
|
||||
elif not self.builder:
|
||||
# We don't know which builder was used to build this
|
||||
# parse tree, so use a default we know is always available.
|
||||
self.builder = HTMLParserTreeBuilder()
|
||||
self.builder.soup = self
|
||||
self.reset()
|
||||
self._feed()
|
||||
return state
|
||||
|
||||
|
||||
@classmethod
|
||||
def _decode_markup(cls, markup):
|
||||
"""Ensure `markup` is bytes so it's safe to send into warnings.warn.
|
||||
|
||||
TODO: warnings.warn had this problem back in 2010 but it might not
|
||||
anymore.
|
||||
"""
|
||||
if isinstance(markup, bytes):
|
||||
decoded = markup.decode('utf-8', 'replace')
|
||||
else:
|
||||
decoded = markup
|
||||
return decoded
|
||||
|
||||
@classmethod
|
||||
def _markup_is_url(cls, markup):
|
||||
"""Error-handling method to raise a warning if incoming markup looks
|
||||
like a URL.
|
||||
|
||||
:param markup: A string.
|
||||
:return: Whether or not the markup resembles a URL
|
||||
closely enough to justify a warning.
|
||||
"""
|
||||
if isinstance(markup, bytes):
|
||||
space = b' '
|
||||
cant_start_with = (b"http:", b"https:")
|
||||
elif isinstance(markup, str):
|
||||
space = ' '
|
||||
cant_start_with = ("http:", "https:")
|
||||
else:
|
||||
return False
|
||||
|
||||
if any(markup.startswith(prefix) for prefix in cant_start_with):
|
||||
if not space in markup:
|
||||
warnings.warn(
|
||||
'The input looks more like a URL than markup. You may want to use'
|
||||
' an HTTP client like requests to get the document behind'
|
||||
' the URL, and feed that document to Beautiful Soup.',
|
||||
MarkupResemblesLocatorWarning,
|
||||
stacklevel=3
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _markup_resembles_filename(cls, markup):
|
||||
"""Error-handling method to raise a warning if incoming markup
|
||||
resembles a filename.
|
||||
|
||||
:param markup: A bytestring or string.
|
||||
:return: Whether or not the markup resembles a filename
|
||||
closely enough to justify a warning.
|
||||
"""
|
||||
path_characters = '/\\'
|
||||
extensions = ['.html', '.htm', '.xml', '.xhtml', '.txt']
|
||||
if isinstance(markup, bytes):
|
||||
path_characters = path_characters.encode("utf8")
|
||||
extensions = [x.encode('utf8') for x in extensions]
|
||||
filelike = False
|
||||
if any(x in markup for x in path_characters):
|
||||
filelike = True
|
||||
else:
|
||||
lower = markup.lower()
|
||||
if any(lower.endswith(ext) for ext in extensions):
|
||||
filelike = True
|
||||
if filelike:
|
||||
warnings.warn(
|
||||
'The input looks more like a filename than markup. You may'
|
||||
' want to open this file and pass the filehandle into'
|
||||
' Beautiful Soup.',
|
||||
MarkupResemblesLocatorWarning, stacklevel=3
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _feed(self):
|
||||
"""Internal method that parses previously set markup, creating a large
|
||||
number of Tag and NavigableString objects.
|
||||
"""
|
||||
# Convert the document to Unicode.
|
||||
self.builder.reset()
|
||||
|
||||
self.builder.feed(self.markup)
|
||||
# Close out any unfinished strings and close all the open tags.
|
||||
self.endData()
|
||||
while self.currentTag.name != self.ROOT_TAG_NAME:
|
||||
self.popTag()
|
||||
|
||||
def reset(self):
|
||||
"""Reset this object to a state as though it had never parsed any
|
||||
markup.
|
||||
"""
|
||||
Tag.__init__(self, self, self.builder, self.ROOT_TAG_NAME)
|
||||
self.hidden = 1
|
||||
self.builder.reset()
|
||||
self.current_data = []
|
||||
self.currentTag = None
|
||||
self.tagStack = []
|
||||
self.open_tag_counter = Counter()
|
||||
self.preserve_whitespace_tag_stack = []
|
||||
self.string_container_stack = []
|
||||
self._most_recent_element = None
|
||||
self.pushTag(self)
|
||||
|
||||
def new_tag(self, name, namespace=None, nsprefix=None, attrs={},
|
||||
sourceline=None, sourcepos=None, **kwattrs):
|
||||
"""Create a new Tag associated with this BeautifulSoup object.
|
||||
|
||||
:param name: The name of the new Tag.
|
||||
:param namespace: The URI of the new Tag's XML namespace, if any.
|
||||
:param prefix: The prefix for the new Tag's XML namespace, if any.
|
||||
:param attrs: A dictionary of this Tag's attribute values; can
|
||||
be used instead of `kwattrs` for attributes like 'class'
|
||||
that are reserved words in Python.
|
||||
:param sourceline: The line number where this tag was
|
||||
(purportedly) found in its source document.
|
||||
:param sourcepos: The character position within `sourceline` where this
|
||||
tag was (purportedly) found.
|
||||
:param kwattrs: Keyword arguments for the new Tag's attribute values.
|
||||
|
||||
"""
|
||||
kwattrs.update(attrs)
|
||||
return self.element_classes.get(Tag, Tag)(
|
||||
None, self.builder, name, namespace, nsprefix, kwattrs,
|
||||
sourceline=sourceline, sourcepos=sourcepos
|
||||
)
|
||||
|
||||
def string_container(self, base_class=None):
|
||||
container = base_class or NavigableString
|
||||
|
||||
# There may be a general override of NavigableString.
|
||||
container = self.element_classes.get(
|
||||
container, container
|
||||
)
|
||||
|
||||
# On top of that, we may be inside a tag that needs a special
|
||||
# container class.
|
||||
if self.string_container_stack and container is NavigableString:
|
||||
container = self.builder.string_containers.get(
|
||||
self.string_container_stack[-1].name, container
|
||||
)
|
||||
return container
|
||||
|
||||
def new_string(self, s, subclass=None):
|
||||
"""Create a new NavigableString associated with this BeautifulSoup
|
||||
object.
|
||||
"""
|
||||
container = self.string_container(subclass)
|
||||
return container(s)
|
||||
|
||||
def insert_before(self, *args):
|
||||
"""This method is part of the PageElement API, but `BeautifulSoup` doesn't implement
|
||||
it because there is nothing before or after it in the parse tree.
|
||||
"""
|
||||
raise NotImplementedError("BeautifulSoup objects don't support insert_before().")
|
||||
|
||||
def insert_after(self, *args):
|
||||
"""This method is part of the PageElement API, but `BeautifulSoup` doesn't implement
|
||||
it because there is nothing before or after it in the parse tree.
|
||||
"""
|
||||
raise NotImplementedError("BeautifulSoup objects don't support insert_after().")
|
||||
|
||||
def popTag(self):
|
||||
"""Internal method called by _popToTag when a tag is closed."""
|
||||
tag = self.tagStack.pop()
|
||||
if tag.name in self.open_tag_counter:
|
||||
self.open_tag_counter[tag.name] -= 1
|
||||
if self.preserve_whitespace_tag_stack and tag == self.preserve_whitespace_tag_stack[-1]:
|
||||
self.preserve_whitespace_tag_stack.pop()
|
||||
if self.string_container_stack and tag == self.string_container_stack[-1]:
|
||||
self.string_container_stack.pop()
|
||||
#print("Pop", tag.name)
|
||||
if self.tagStack:
|
||||
self.currentTag = self.tagStack[-1]
|
||||
return self.currentTag
|
||||
|
||||
def pushTag(self, tag):
|
||||
"""Internal method called by handle_starttag when a tag is opened."""
|
||||
#print("Push", tag.name)
|
||||
if self.currentTag is not None:
|
||||
self.currentTag.contents.append(tag)
|
||||
self.tagStack.append(tag)
|
||||
self.currentTag = self.tagStack[-1]
|
||||
if tag.name != self.ROOT_TAG_NAME:
|
||||
self.open_tag_counter[tag.name] += 1
|
||||
if tag.name in self.builder.preserve_whitespace_tags:
|
||||
self.preserve_whitespace_tag_stack.append(tag)
|
||||
if tag.name in self.builder.string_containers:
|
||||
self.string_container_stack.append(tag)
|
||||
|
||||
def endData(self, containerClass=None):
|
||||
"""Method called by the TreeBuilder when the end of a data segment
|
||||
occurs.
|
||||
"""
|
||||
if self.current_data:
|
||||
current_data = ''.join(self.current_data)
|
||||
# If whitespace is not preserved, and this string contains
|
||||
# nothing but ASCII spaces, replace it with a single space
|
||||
# or newline.
|
||||
if not self.preserve_whitespace_tag_stack:
|
||||
strippable = True
|
||||
for i in current_data:
|
||||
if i not in self.ASCII_SPACES:
|
||||
strippable = False
|
||||
break
|
||||
if strippable:
|
||||
if '\n' in current_data:
|
||||
current_data = '\n'
|
||||
else:
|
||||
current_data = ' '
|
||||
|
||||
# Reset the data collector.
|
||||
self.current_data = []
|
||||
|
||||
# Should we add this string to the tree at all?
|
||||
if self.parse_only and len(self.tagStack) <= 1 and \
|
||||
(not self.parse_only.text or \
|
||||
not self.parse_only.search(current_data)):
|
||||
return
|
||||
|
||||
containerClass = self.string_container(containerClass)
|
||||
o = containerClass(current_data)
|
||||
self.object_was_parsed(o)
|
||||
|
||||
def object_was_parsed(self, o, parent=None, most_recent_element=None):
|
||||
"""Method called by the TreeBuilder to integrate an object into the parse tree."""
|
||||
if parent is None:
|
||||
parent = self.currentTag
|
||||
if most_recent_element is not None:
|
||||
previous_element = most_recent_element
|
||||
else:
|
||||
previous_element = self._most_recent_element
|
||||
|
||||
next_element = previous_sibling = next_sibling = None
|
||||
if isinstance(o, Tag):
|
||||
next_element = o.next_element
|
||||
next_sibling = o.next_sibling
|
||||
previous_sibling = o.previous_sibling
|
||||
if previous_element is None:
|
||||
previous_element = o.previous_element
|
||||
|
||||
fix = parent.next_element is not None
|
||||
|
||||
o.setup(parent, previous_element, next_element, previous_sibling, next_sibling)
|
||||
|
||||
self._most_recent_element = o
|
||||
parent.contents.append(o)
|
||||
|
||||
# Check if we are inserting into an already parsed node.
|
||||
if fix:
|
||||
self._linkage_fixer(parent)
|
||||
|
||||
def _linkage_fixer(self, el):
|
||||
"""Make sure linkage of this fragment is sound."""
|
||||
|
||||
first = el.contents[0]
|
||||
child = el.contents[-1]
|
||||
descendant = child
|
||||
|
||||
if child is first and el.parent is not None:
|
||||
# Parent should be linked to first child
|
||||
el.next_element = child
|
||||
# We are no longer linked to whatever this element is
|
||||
prev_el = child.previous_element
|
||||
if prev_el is not None and prev_el is not el:
|
||||
prev_el.next_element = None
|
||||
# First child should be linked to the parent, and no previous siblings.
|
||||
child.previous_element = el
|
||||
child.previous_sibling = None
|
||||
|
||||
# We have no sibling as we've been appended as the last.
|
||||
child.next_sibling = None
|
||||
|
||||
# This index is a tag, dig deeper for a "last descendant"
|
||||
if isinstance(child, Tag) and child.contents:
|
||||
descendant = child._last_descendant(False)
|
||||
|
||||
# As the final step, link last descendant. It should be linked
|
||||
# to the parent's next sibling (if found), else walk up the chain
|
||||
# and find a parent with a sibling. It should have no next sibling.
|
||||
descendant.next_element = None
|
||||
descendant.next_sibling = None
|
||||
target = el
|
||||
while True:
|
||||
if target is None:
|
||||
break
|
||||
elif target.next_sibling is not None:
|
||||
descendant.next_element = target.next_sibling
|
||||
target.next_sibling.previous_element = child
|
||||
break
|
||||
target = target.parent
|
||||
|
||||
def _popToTag(self, name, nsprefix=None, inclusivePop=True):
|
||||
"""Pops the tag stack up to and including the most recent
|
||||
instance of the given tag.
|
||||
|
||||
If there are no open tags with the given name, nothing will be
|
||||
popped.
|
||||
|
||||
:param name: Pop up to the most recent tag with this name.
|
||||
:param nsprefix: The namespace prefix that goes with `name`.
|
||||
:param inclusivePop: It this is false, pops the tag stack up
|
||||
to but *not* including the most recent instqance of the
|
||||
given tag.
|
||||
|
||||
"""
|
||||
#print("Popping to %s" % name)
|
||||
if name == self.ROOT_TAG_NAME:
|
||||
# The BeautifulSoup object itself can never be popped.
|
||||
return
|
||||
|
||||
most_recently_popped = None
|
||||
|
||||
stack_size = len(self.tagStack)
|
||||
for i in range(stack_size - 1, 0, -1):
|
||||
if not self.open_tag_counter.get(name):
|
||||
break
|
||||
t = self.tagStack[i]
|
||||
if (name == t.name and nsprefix == t.prefix):
|
||||
if inclusivePop:
|
||||
most_recently_popped = self.popTag()
|
||||
break
|
||||
most_recently_popped = self.popTag()
|
||||
|
||||
return most_recently_popped
|
||||
|
||||
def handle_starttag(self, name, namespace, nsprefix, attrs, sourceline=None,
|
||||
sourcepos=None, namespaces=None):
|
||||
"""Called by the tree builder when a new tag is encountered.
|
||||
|
||||
:param name: Name of the tag.
|
||||
:param nsprefix: Namespace prefix for the tag.
|
||||
:param attrs: A dictionary of attribute values.
|
||||
:param sourceline: The line number where this tag was found in its
|
||||
source document.
|
||||
:param sourcepos: The character position within `sourceline` where this
|
||||
tag was found.
|
||||
:param namespaces: A dictionary of all namespace prefix mappings
|
||||
currently in scope in the document.
|
||||
|
||||
If this method returns None, the tag was rejected by an active
|
||||
SoupStrainer. You should proceed as if the tag had not occurred
|
||||
in the document. For instance, if this was a self-closing tag,
|
||||
don't call handle_endtag.
|
||||
"""
|
||||
# print("Start tag %s: %s" % (name, attrs))
|
||||
self.endData()
|
||||
|
||||
if (self.parse_only and len(self.tagStack) <= 1
|
||||
and (self.parse_only.text
|
||||
or not self.parse_only.search_tag(name, attrs))):
|
||||
return None
|
||||
|
||||
tag = self.element_classes.get(Tag, Tag)(
|
||||
self, self.builder, name, namespace, nsprefix, attrs,
|
||||
self.currentTag, self._most_recent_element,
|
||||
sourceline=sourceline, sourcepos=sourcepos,
|
||||
namespaces=namespaces
|
||||
)
|
||||
if tag is None:
|
||||
return tag
|
||||
if self._most_recent_element is not None:
|
||||
self._most_recent_element.next_element = tag
|
||||
self._most_recent_element = tag
|
||||
self.pushTag(tag)
|
||||
return tag
|
||||
|
||||
def handle_endtag(self, name, nsprefix=None):
|
||||
"""Called by the tree builder when an ending tag is encountered.
|
||||
|
||||
:param name: Name of the tag.
|
||||
:param nsprefix: Namespace prefix for the tag.
|
||||
"""
|
||||
#print("End tag: " + name)
|
||||
self.endData()
|
||||
self._popToTag(name, nsprefix)
|
||||
|
||||
def handle_data(self, data):
|
||||
"""Called by the tree builder when a chunk of textual data is encountered."""
|
||||
self.current_data.append(data)
|
||||
|
||||
def decode(self, pretty_print=False,
|
||||
eventual_encoding=DEFAULT_OUTPUT_ENCODING,
|
||||
formatter="minimal", iterator=None):
|
||||
"""Returns a string or Unicode representation of the parse tree
|
||||
as an HTML or XML document.
|
||||
|
||||
:param pretty_print: If this is True, indentation will be used to
|
||||
make the document more readable.
|
||||
:param eventual_encoding: The encoding of the final document.
|
||||
If this is None, the document will be a Unicode string.
|
||||
"""
|
||||
if self.is_xml:
|
||||
# Print the XML declaration
|
||||
encoding_part = ''
|
||||
if eventual_encoding in PYTHON_SPECIFIC_ENCODINGS:
|
||||
# This is a special Python encoding; it can't actually
|
||||
# go into an XML document because it means nothing
|
||||
# outside of Python.
|
||||
eventual_encoding = None
|
||||
if eventual_encoding != None:
|
||||
encoding_part = ' encoding="%s"' % eventual_encoding
|
||||
prefix = '<?xml version="1.0"%s?>\n' % encoding_part
|
||||
else:
|
||||
prefix = ''
|
||||
if not pretty_print:
|
||||
indent_level = None
|
||||
else:
|
||||
indent_level = 0
|
||||
return prefix + super(BeautifulSoup, self).decode(
|
||||
indent_level, eventual_encoding, formatter, iterator)
|
||||
|
||||
# Aliases to make it easier to get started quickly, e.g. 'from bs4 import _soup'
|
||||
_s = BeautifulSoup
|
||||
_soup = BeautifulSoup
|
||||
|
||||
class BeautifulStoneSoup(BeautifulSoup):
|
||||
"""Deprecated interface to an XML parser."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['features'] = 'xml'
|
||||
warnings.warn(
|
||||
'The BeautifulStoneSoup class is deprecated. Instead of using '
|
||||
'it, pass features="xml" into the BeautifulSoup constructor.',
|
||||
DeprecationWarning, stacklevel=2
|
||||
)
|
||||
super(BeautifulStoneSoup, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class StopParsing(Exception):
|
||||
"""Exception raised by a TreeBuilder if it's unable to continue parsing."""
|
||||
pass
|
||||
|
||||
class FeatureNotFound(ValueError):
|
||||
"""Exception raised by the BeautifulSoup constructor if no parser with the
|
||||
requested features is found.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
#If this file is run as a script, act as an HTML pretty-printer.
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
soup = BeautifulSoup(sys.stdin)
|
||||
print((soup.prettify()))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,636 @@
|
||||
# Use of this source code is governed by the MIT license.
|
||||
__license__ = "MIT"
|
||||
|
||||
from collections import defaultdict
|
||||
import itertools
|
||||
import re
|
||||
import warnings
|
||||
import sys
|
||||
from bs4.element import (
|
||||
CharsetMetaAttributeValue,
|
||||
ContentMetaAttributeValue,
|
||||
RubyParenthesisString,
|
||||
RubyTextString,
|
||||
Stylesheet,
|
||||
Script,
|
||||
TemplateString,
|
||||
nonwhitespace_re
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'HTMLTreeBuilder',
|
||||
'SAXTreeBuilder',
|
||||
'TreeBuilder',
|
||||
'TreeBuilderRegistry',
|
||||
]
|
||||
|
||||
# Some useful features for a TreeBuilder to have.
|
||||
FAST = 'fast'
|
||||
PERMISSIVE = 'permissive'
|
||||
STRICT = 'strict'
|
||||
XML = 'xml'
|
||||
HTML = 'html'
|
||||
HTML_5 = 'html5'
|
||||
|
||||
class XMLParsedAsHTMLWarning(UserWarning):
|
||||
"""The warning issued when an HTML parser is used to parse
|
||||
XML that is not XHTML.
|
||||
"""
|
||||
MESSAGE = """It looks like you're parsing an XML document using an HTML parser. If this really is an HTML document (maybe it's XHTML?), you can ignore or filter this warning. If it's XML, you should know that using an XML parser will be more reliable. To parse this document as XML, make sure you have the lxml package installed, and pass the keyword argument `features="xml"` into the BeautifulSoup constructor."""
|
||||
|
||||
|
||||
class TreeBuilderRegistry(object):
|
||||
"""A way of looking up TreeBuilder subclasses by their name or by desired
|
||||
features.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.builders_for_feature = defaultdict(list)
|
||||
self.builders = []
|
||||
|
||||
def register(self, treebuilder_class):
|
||||
"""Register a treebuilder based on its advertised features.
|
||||
|
||||
:param treebuilder_class: A subclass of Treebuilder. its .features
|
||||
attribute should list its features.
|
||||
"""
|
||||
for feature in treebuilder_class.features:
|
||||
self.builders_for_feature[feature].insert(0, treebuilder_class)
|
||||
self.builders.insert(0, treebuilder_class)
|
||||
|
||||
def lookup(self, *features):
|
||||
"""Look up a TreeBuilder subclass with the desired features.
|
||||
|
||||
:param features: A list of features to look for. If none are
|
||||
provided, the most recently registered TreeBuilder subclass
|
||||
will be used.
|
||||
:return: A TreeBuilder subclass, or None if there's no
|
||||
registered subclass with all the requested features.
|
||||
"""
|
||||
if len(self.builders) == 0:
|
||||
# There are no builders at all.
|
||||
return None
|
||||
|
||||
if len(features) == 0:
|
||||
# They didn't ask for any features. Give them the most
|
||||
# recently registered builder.
|
||||
return self.builders[0]
|
||||
|
||||
# Go down the list of features in order, and eliminate any builders
|
||||
# that don't match every feature.
|
||||
features = list(features)
|
||||
features.reverse()
|
||||
candidates = None
|
||||
candidate_set = None
|
||||
while len(features) > 0:
|
||||
feature = features.pop()
|
||||
we_have_the_feature = self.builders_for_feature.get(feature, [])
|
||||
if len(we_have_the_feature) > 0:
|
||||
if candidates is None:
|
||||
candidates = we_have_the_feature
|
||||
candidate_set = set(candidates)
|
||||
else:
|
||||
# Eliminate any candidates that don't have this feature.
|
||||
candidate_set = candidate_set.intersection(
|
||||
set(we_have_the_feature))
|
||||
|
||||
# The only valid candidates are the ones in candidate_set.
|
||||
# Go through the original list of candidates and pick the first one
|
||||
# that's in candidate_set.
|
||||
if candidate_set is None:
|
||||
return None
|
||||
for candidate in candidates:
|
||||
if candidate in candidate_set:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
# The BeautifulSoup class will take feature lists from developers and use them
|
||||
# to look up builders in this registry.
|
||||
builder_registry = TreeBuilderRegistry()
|
||||
|
||||
class TreeBuilder(object):
|
||||
"""Turn a textual document into a Beautiful Soup object tree."""
|
||||
|
||||
NAME = "[Unknown tree builder]"
|
||||
ALTERNATE_NAMES = []
|
||||
features = []
|
||||
|
||||
is_xml = False
|
||||
picklable = False
|
||||
empty_element_tags = None # A tag will be considered an empty-element
|
||||
# tag when and only when it has no contents.
|
||||
|
||||
# A value for these tag/attribute combinations is a space- or
|
||||
# comma-separated list of CDATA, rather than a single CDATA.
|
||||
DEFAULT_CDATA_LIST_ATTRIBUTES = defaultdict(list)
|
||||
|
||||
# Whitespace should be preserved inside these tags.
|
||||
DEFAULT_PRESERVE_WHITESPACE_TAGS = set()
|
||||
|
||||
# The textual contents of tags with these names should be
|
||||
# instantiated with some class other than NavigableString.
|
||||
DEFAULT_STRING_CONTAINERS = {}
|
||||
|
||||
USE_DEFAULT = object()
|
||||
|
||||
# Most parsers don't keep track of line numbers.
|
||||
TRACKS_LINE_NUMBERS = False
|
||||
|
||||
def __init__(self, multi_valued_attributes=USE_DEFAULT,
|
||||
preserve_whitespace_tags=USE_DEFAULT,
|
||||
store_line_numbers=USE_DEFAULT,
|
||||
string_containers=USE_DEFAULT,
|
||||
):
|
||||
"""Constructor.
|
||||
|
||||
:param multi_valued_attributes: If this is set to None, the
|
||||
TreeBuilder will not turn any values for attributes like
|
||||
'class' into lists. Setting this to a dictionary will
|
||||
customize this behavior; look at DEFAULT_CDATA_LIST_ATTRIBUTES
|
||||
for an example.
|
||||
|
||||
Internally, these are called "CDATA list attributes", but that
|
||||
probably doesn't make sense to an end-user, so the argument name
|
||||
is `multi_valued_attributes`.
|
||||
|
||||
:param preserve_whitespace_tags: A list of tags to treat
|
||||
the way <pre> tags are treated in HTML. Tags in this list
|
||||
are immune from pretty-printing; their contents will always be
|
||||
output as-is.
|
||||
|
||||
:param string_containers: A dictionary mapping tag names to
|
||||
the classes that should be instantiated to contain the textual
|
||||
contents of those tags. The default is to use NavigableString
|
||||
for every tag, no matter what the name. You can override the
|
||||
default by changing DEFAULT_STRING_CONTAINERS.
|
||||
|
||||
:param store_line_numbers: If the parser keeps track of the
|
||||
line numbers and positions of the original markup, that
|
||||
information will, by default, be stored in each corresponding
|
||||
`Tag` object. You can turn this off by passing
|
||||
store_line_numbers=False. If the parser you're using doesn't
|
||||
keep track of this information, then setting store_line_numbers=True
|
||||
will do nothing.
|
||||
"""
|
||||
self.soup = None
|
||||
if multi_valued_attributes is self.USE_DEFAULT:
|
||||
multi_valued_attributes = self.DEFAULT_CDATA_LIST_ATTRIBUTES
|
||||
self.cdata_list_attributes = multi_valued_attributes
|
||||
if preserve_whitespace_tags is self.USE_DEFAULT:
|
||||
preserve_whitespace_tags = self.DEFAULT_PRESERVE_WHITESPACE_TAGS
|
||||
self.preserve_whitespace_tags = preserve_whitespace_tags
|
||||
if store_line_numbers == self.USE_DEFAULT:
|
||||
store_line_numbers = self.TRACKS_LINE_NUMBERS
|
||||
self.store_line_numbers = store_line_numbers
|
||||
if string_containers == self.USE_DEFAULT:
|
||||
string_containers = self.DEFAULT_STRING_CONTAINERS
|
||||
self.string_containers = string_containers
|
||||
|
||||
def initialize_soup(self, soup):
|
||||
"""The BeautifulSoup object has been initialized and is now
|
||||
being associated with the TreeBuilder.
|
||||
|
||||
:param soup: A BeautifulSoup object.
|
||||
"""
|
||||
self.soup = soup
|
||||
|
||||
def reset(self):
|
||||
"""Do any work necessary to reset the underlying parser
|
||||
for a new document.
|
||||
|
||||
By default, this does nothing.
|
||||
"""
|
||||
pass
|
||||
|
||||
def can_be_empty_element(self, tag_name):
|
||||
"""Might a tag with this name be an empty-element tag?
|
||||
|
||||
The final markup may or may not actually present this tag as
|
||||
self-closing.
|
||||
|
||||
For instance: an HTMLBuilder does not consider a <p> tag to be
|
||||
an empty-element tag (it's not in
|
||||
HTMLBuilder.empty_element_tags). This means an empty <p> tag
|
||||
will be presented as "<p></p>", not "<p/>" or "<p>".
|
||||
|
||||
The default implementation has no opinion about which tags are
|
||||
empty-element tags, so a tag will be presented as an
|
||||
empty-element tag if and only if it has no children.
|
||||
"<foo></foo>" will become "<foo/>", and "<foo>bar</foo>" will
|
||||
be left alone.
|
||||
|
||||
:param tag_name: The name of a markup tag.
|
||||
"""
|
||||
if self.empty_element_tags is None:
|
||||
return True
|
||||
return tag_name in self.empty_element_tags
|
||||
|
||||
def feed(self, markup):
|
||||
"""Run some incoming markup through some parsing process,
|
||||
populating the `BeautifulSoup` object in self.soup.
|
||||
|
||||
This method is not implemented in TreeBuilder; it must be
|
||||
implemented in subclasses.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
"""Run any preliminary steps necessary to make incoming markup
|
||||
acceptable to the parser.
|
||||
|
||||
:param markup: Some markup -- probably a bytestring.
|
||||
:param user_specified_encoding: The user asked to try this encoding.
|
||||
:param document_declared_encoding: The markup itself claims to be
|
||||
in this encoding. NOTE: This argument is not used by the
|
||||
calling code and can probably be removed.
|
||||
:param exclude_encodings: The user asked _not_ to try any of
|
||||
these encodings.
|
||||
|
||||
:yield: A series of 4-tuples:
|
||||
(markup, encoding, declared encoding,
|
||||
has undergone character replacement)
|
||||
|
||||
Each 4-tuple represents a strategy for converting the
|
||||
document to Unicode and parsing it. Each strategy will be tried
|
||||
in turn.
|
||||
|
||||
By default, the only strategy is to parse the markup
|
||||
as-is. See `LXMLTreeBuilderForXML` and
|
||||
`HTMLParserTreeBuilder` for implementations that take into
|
||||
account the quirks of particular parsers.
|
||||
"""
|
||||
yield markup, None, None, False
|
||||
|
||||
def test_fragment_to_document(self, fragment):
|
||||
"""Wrap an HTML fragment to make it look like a document.
|
||||
|
||||
Different parsers do this differently. For instance, lxml
|
||||
introduces an empty <head> tag, and html5lib
|
||||
doesn't. Abstracting this away lets us write simple tests
|
||||
which run HTML fragments through the parser and compare the
|
||||
results against other HTML fragments.
|
||||
|
||||
This method should not be used outside of tests.
|
||||
|
||||
:param fragment: A string -- fragment of HTML.
|
||||
:return: A string -- a full HTML document.
|
||||
"""
|
||||
return fragment
|
||||
|
||||
def set_up_substitutions(self, tag):
|
||||
"""Set up any substitutions that will need to be performed on
|
||||
a `Tag` when it's output as a string.
|
||||
|
||||
By default, this does nothing. See `HTMLTreeBuilder` for a
|
||||
case where this is used.
|
||||
|
||||
:param tag: A `Tag`
|
||||
:return: Whether or not a substitution was performed.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _replace_cdata_list_attribute_values(self, tag_name, attrs):
|
||||
"""When an attribute value is associated with a tag that can
|
||||
have multiple values for that attribute, convert the string
|
||||
value to a list of strings.
|
||||
|
||||
Basically, replaces class="foo bar" with class=["foo", "bar"]
|
||||
|
||||
NOTE: This method modifies its input in place.
|
||||
|
||||
:param tag_name: The name of a tag.
|
||||
:param attrs: A dictionary containing the tag's attributes.
|
||||
Any appropriate attribute values will be modified in place.
|
||||
"""
|
||||
if not attrs:
|
||||
return attrs
|
||||
if self.cdata_list_attributes:
|
||||
universal = self.cdata_list_attributes.get('*', [])
|
||||
tag_specific = self.cdata_list_attributes.get(
|
||||
tag_name.lower(), None)
|
||||
for attr in list(attrs.keys()):
|
||||
if attr in universal or (tag_specific and attr in tag_specific):
|
||||
# We have a "class"-type attribute whose string
|
||||
# value is a whitespace-separated list of
|
||||
# values. Split it into a list.
|
||||
value = attrs[attr]
|
||||
if isinstance(value, str):
|
||||
values = nonwhitespace_re.findall(value)
|
||||
else:
|
||||
# html5lib sometimes calls setAttributes twice
|
||||
# for the same tag when rearranging the parse
|
||||
# tree. On the second call the attribute value
|
||||
# here is already a list. If this happens,
|
||||
# leave the value alone rather than trying to
|
||||
# split it again.
|
||||
values = value
|
||||
attrs[attr] = values
|
||||
return attrs
|
||||
|
||||
class SAXTreeBuilder(TreeBuilder):
|
||||
"""A Beautiful Soup treebuilder that listens for SAX events.
|
||||
|
||||
This is not currently used for anything, but it demonstrates
|
||||
how a simple TreeBuilder would work.
|
||||
"""
|
||||
|
||||
def feed(self, markup):
|
||||
raise NotImplementedError()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def startElement(self, name, attrs):
|
||||
attrs = dict((key[1], value) for key, value in list(attrs.items()))
|
||||
#print("Start %s, %r" % (name, attrs))
|
||||
self.soup.handle_starttag(name, attrs)
|
||||
|
||||
def endElement(self, name):
|
||||
#print("End %s" % name)
|
||||
self.soup.handle_endtag(name)
|
||||
|
||||
def startElementNS(self, nsTuple, nodeName, attrs):
|
||||
# Throw away (ns, nodeName) for now.
|
||||
self.startElement(nodeName, attrs)
|
||||
|
||||
def endElementNS(self, nsTuple, nodeName):
|
||||
# Throw away (ns, nodeName) for now.
|
||||
self.endElement(nodeName)
|
||||
#handler.endElementNS((ns, node.nodeName), node.nodeName)
|
||||
|
||||
def startPrefixMapping(self, prefix, nodeValue):
|
||||
# Ignore the prefix for now.
|
||||
pass
|
||||
|
||||
def endPrefixMapping(self, prefix):
|
||||
# Ignore the prefix for now.
|
||||
# handler.endPrefixMapping(prefix)
|
||||
pass
|
||||
|
||||
def characters(self, content):
|
||||
self.soup.handle_data(content)
|
||||
|
||||
def startDocument(self):
|
||||
pass
|
||||
|
||||
def endDocument(self):
|
||||
pass
|
||||
|
||||
|
||||
class HTMLTreeBuilder(TreeBuilder):
|
||||
"""This TreeBuilder knows facts about HTML.
|
||||
|
||||
Such as which tags are empty-element tags.
|
||||
"""
|
||||
|
||||
empty_element_tags = set([
|
||||
# These are from HTML5.
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
|
||||
# These are from earlier versions of HTML and are removed in HTML5.
|
||||
'basefont', 'bgsound', 'command', 'frame', 'image', 'isindex', 'nextid', 'spacer'
|
||||
])
|
||||
|
||||
# The HTML standard defines these as block-level elements. Beautiful
|
||||
# Soup does not treat these elements differently from other elements,
|
||||
# but it may do so eventually, and this information is available if
|
||||
# you need to use it.
|
||||
block_elements = set(["address", "article", "aside", "blockquote", "canvas", "dd", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "noscript", "ol", "output", "p", "pre", "section", "table", "tfoot", "ul", "video"])
|
||||
|
||||
# These HTML tags need special treatment so they can be
|
||||
# represented by a string class other than NavigableString.
|
||||
#
|
||||
# For some of these tags, it's because the HTML standard defines
|
||||
# an unusual content model for them. I made this list by going
|
||||
# through the HTML spec
|
||||
# (https://html.spec.whatwg.org/#metadata-content) and looking for
|
||||
# "metadata content" elements that can contain strings.
|
||||
#
|
||||
# The Ruby tags (<rt> and <rp>) are here despite being normal
|
||||
# "phrasing content" tags, because the content they contain is
|
||||
# qualitatively different from other text in the document, and it
|
||||
# can be useful to be able to distinguish it.
|
||||
#
|
||||
# TODO: Arguably <noscript> could go here but it seems
|
||||
# qualitatively different from the other tags.
|
||||
DEFAULT_STRING_CONTAINERS = {
|
||||
'rt' : RubyTextString,
|
||||
'rp' : RubyParenthesisString,
|
||||
'style': Stylesheet,
|
||||
'script': Script,
|
||||
'template': TemplateString,
|
||||
}
|
||||
|
||||
# The HTML standard defines these attributes as containing a
|
||||
# space-separated list of values, not a single value. That is,
|
||||
# class="foo bar" means that the 'class' attribute has two values,
|
||||
# 'foo' and 'bar', not the single value 'foo bar'. When we
|
||||
# encounter one of these attributes, we will parse its value into
|
||||
# a list of values if possible. Upon output, the list will be
|
||||
# converted back into a string.
|
||||
DEFAULT_CDATA_LIST_ATTRIBUTES = {
|
||||
"*" : ['class', 'accesskey', 'dropzone'],
|
||||
"a" : ['rel', 'rev'],
|
||||
"link" : ['rel', 'rev'],
|
||||
"td" : ["headers"],
|
||||
"th" : ["headers"],
|
||||
"td" : ["headers"],
|
||||
"form" : ["accept-charset"],
|
||||
"object" : ["archive"],
|
||||
|
||||
# These are HTML5 specific, as are *.accesskey and *.dropzone above.
|
||||
"area" : ["rel"],
|
||||
"icon" : ["sizes"],
|
||||
"iframe" : ["sandbox"],
|
||||
"output" : ["for"],
|
||||
}
|
||||
|
||||
DEFAULT_PRESERVE_WHITESPACE_TAGS = set(['pre', 'textarea'])
|
||||
|
||||
def set_up_substitutions(self, tag):
|
||||
"""Replace the declared encoding in a <meta> tag with a placeholder,
|
||||
to be substituted when the tag is output to a string.
|
||||
|
||||
An HTML document may come in to Beautiful Soup as one
|
||||
encoding, but exit in a different encoding, and the <meta> tag
|
||||
needs to be changed to reflect this.
|
||||
|
||||
:param tag: A `Tag`
|
||||
:return: Whether or not a substitution was performed.
|
||||
"""
|
||||
# We are only interested in <meta> tags
|
||||
if tag.name != 'meta':
|
||||
return False
|
||||
|
||||
http_equiv = tag.get('http-equiv')
|
||||
content = tag.get('content')
|
||||
charset = tag.get('charset')
|
||||
|
||||
# We are interested in <meta> tags that say what encoding the
|
||||
# document was originally in. This means HTML 5-style <meta>
|
||||
# tags that provide the "charset" attribute. It also means
|
||||
# HTML 4-style <meta> tags that provide the "content"
|
||||
# attribute and have "http-equiv" set to "content-type".
|
||||
#
|
||||
# In both cases we will replace the value of the appropriate
|
||||
# attribute with a standin object that can take on any
|
||||
# encoding.
|
||||
meta_encoding = None
|
||||
if charset is not None:
|
||||
# HTML 5 style:
|
||||
# <meta charset="utf8">
|
||||
meta_encoding = charset
|
||||
tag['charset'] = CharsetMetaAttributeValue(charset)
|
||||
|
||||
elif (content is not None and http_equiv is not None
|
||||
and http_equiv.lower() == 'content-type'):
|
||||
# HTML 4 style:
|
||||
# <meta http-equiv="content-type" content="text/html; charset=utf8">
|
||||
tag['content'] = ContentMetaAttributeValue(content)
|
||||
|
||||
return (meta_encoding is not None)
|
||||
|
||||
class DetectsXMLParsedAsHTML(object):
|
||||
"""A mixin class for any class (a TreeBuilder, or some class used by a
|
||||
TreeBuilder) that's in a position to detect whether an XML
|
||||
document is being incorrectly parsed as HTML, and issue an
|
||||
appropriate warning.
|
||||
|
||||
This requires being able to observe an incoming processing
|
||||
instruction that might be an XML declaration, and also able to
|
||||
observe tags as they're opened. If you can't do that for a given
|
||||
TreeBuilder, there's a less reliable implementation based on
|
||||
examining the raw markup.
|
||||
"""
|
||||
|
||||
# Regular expression for seeing if markup has an <html> tag.
|
||||
LOOKS_LIKE_HTML = re.compile("<[^ +]html", re.I)
|
||||
LOOKS_LIKE_HTML_B = re.compile(b"<[^ +]html", re.I)
|
||||
|
||||
XML_PREFIX = '<?xml'
|
||||
XML_PREFIX_B = b'<?xml'
|
||||
|
||||
@classmethod
|
||||
def warn_if_markup_looks_like_xml(cls, markup, stacklevel=3):
|
||||
"""Perform a check on some markup to see if it looks like XML
|
||||
that's not XHTML. If so, issue a warning.
|
||||
|
||||
This is much less reliable than doing the check while parsing,
|
||||
but some of the tree builders can't do that.
|
||||
|
||||
:param stacklevel: The stacklevel of the code calling this
|
||||
function.
|
||||
|
||||
:return: True if the markup looks like non-XHTML XML, False
|
||||
otherwise.
|
||||
|
||||
"""
|
||||
if isinstance(markup, bytes):
|
||||
prefix = cls.XML_PREFIX_B
|
||||
looks_like_html = cls.LOOKS_LIKE_HTML_B
|
||||
else:
|
||||
prefix = cls.XML_PREFIX
|
||||
looks_like_html = cls.LOOKS_LIKE_HTML
|
||||
|
||||
if (markup is not None
|
||||
and markup.startswith(prefix)
|
||||
and not looks_like_html.search(markup[:500])
|
||||
):
|
||||
cls._warn(stacklevel=stacklevel+2)
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _warn(cls, stacklevel=5):
|
||||
"""Issue a warning about XML being parsed as HTML."""
|
||||
warnings.warn(
|
||||
XMLParsedAsHTMLWarning.MESSAGE, XMLParsedAsHTMLWarning,
|
||||
stacklevel=stacklevel
|
||||
)
|
||||
|
||||
def _initialize_xml_detector(self):
|
||||
"""Call this method before parsing a document."""
|
||||
self._first_processing_instruction = None
|
||||
self._root_tag = None
|
||||
|
||||
def _document_might_be_xml(self, processing_instruction):
|
||||
"""Call this method when encountering an XML declaration, or a
|
||||
"processing instruction" that might be an XML declaration.
|
||||
"""
|
||||
if (self._first_processing_instruction is not None
|
||||
or self._root_tag is not None):
|
||||
# The document has already started. Don't bother checking
|
||||
# anymore.
|
||||
return
|
||||
|
||||
self._first_processing_instruction = processing_instruction
|
||||
|
||||
# We won't know until we encounter the first tag whether or
|
||||
# not this is actually a problem.
|
||||
|
||||
def _root_tag_encountered(self, name):
|
||||
"""Call this when you encounter the document's root tag.
|
||||
|
||||
This is where we actually check whether an XML document is
|
||||
being incorrectly parsed as HTML, and issue the warning.
|
||||
"""
|
||||
if self._root_tag is not None:
|
||||
# This method was incorrectly called multiple times. Do
|
||||
# nothing.
|
||||
return
|
||||
|
||||
self._root_tag = name
|
||||
if (name != 'html' and self._first_processing_instruction is not None
|
||||
and self._first_processing_instruction.lower().startswith('xml ')):
|
||||
# We encountered an XML declaration and then a tag other
|
||||
# than 'html'. This is a reliable indicator that a
|
||||
# non-XHTML document is being parsed as XML.
|
||||
self._warn()
|
||||
|
||||
|
||||
def register_treebuilders_from(module):
|
||||
"""Copy TreeBuilders from the given module into this module."""
|
||||
this_module = sys.modules[__name__]
|
||||
for name in module.__all__:
|
||||
obj = getattr(module, name)
|
||||
|
||||
if issubclass(obj, TreeBuilder):
|
||||
setattr(this_module, name, obj)
|
||||
this_module.__all__.append(name)
|
||||
# Register the builder while we're at it.
|
||||
this_module.builder_registry.register(obj)
|
||||
|
||||
class ParserRejectedMarkup(Exception):
|
||||
"""An Exception to be raised when the underlying parser simply
|
||||
refuses to parse the given markup.
|
||||
"""
|
||||
def __init__(self, message_or_exception):
|
||||
"""Explain why the parser rejected the given markup, either
|
||||
with a textual explanation or another exception.
|
||||
"""
|
||||
if isinstance(message_or_exception, Exception):
|
||||
e = message_or_exception
|
||||
message_or_exception = "%s: %s" % (e.__class__.__name__, str(e))
|
||||
super(ParserRejectedMarkup, self).__init__(message_or_exception)
|
||||
|
||||
# Builders are registered in reverse order of priority, so that custom
|
||||
# builder registrations will take precedence. In general, we want lxml
|
||||
# to take precedence over html5lib, because it's faster. And we only
|
||||
# want to use HTMLParser as a last resort.
|
||||
from . import _htmlparser
|
||||
register_treebuilders_from(_htmlparser)
|
||||
try:
|
||||
from . import _html5lib
|
||||
register_treebuilders_from(_html5lib)
|
||||
except ImportError:
|
||||
# They don't have html5lib installed.
|
||||
pass
|
||||
try:
|
||||
from . import _lxml
|
||||
register_treebuilders_from(_lxml)
|
||||
except ImportError:
|
||||
# They don't have lxml installed.
|
||||
pass
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,481 @@
|
||||
# Use of this source code is governed by the MIT license.
|
||||
__license__ = "MIT"
|
||||
|
||||
__all__ = [
|
||||
'HTML5TreeBuilder',
|
||||
]
|
||||
|
||||
import warnings
|
||||
import re
|
||||
from bs4.builder import (
|
||||
DetectsXMLParsedAsHTML,
|
||||
PERMISSIVE,
|
||||
HTML,
|
||||
HTML_5,
|
||||
HTMLTreeBuilder,
|
||||
)
|
||||
from bs4.element import (
|
||||
NamespacedAttribute,
|
||||
nonwhitespace_re,
|
||||
)
|
||||
import html5lib
|
||||
from html5lib.constants import (
|
||||
namespaces,
|
||||
prefixes,
|
||||
)
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
Doctype,
|
||||
NavigableString,
|
||||
Tag,
|
||||
)
|
||||
|
||||
try:
|
||||
# Pre-0.99999999
|
||||
from html5lib.treebuilders import _base as treebuilder_base
|
||||
new_html5lib = False
|
||||
except ImportError as e:
|
||||
# 0.99999999 and up
|
||||
from html5lib.treebuilders import base as treebuilder_base
|
||||
new_html5lib = True
|
||||
|
||||
class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
"""Use html5lib to build a tree.
|
||||
|
||||
Note that this TreeBuilder does not support some features common
|
||||
to HTML TreeBuilders. Some of these features could theoretically
|
||||
be implemented, but at the very least it's quite difficult,
|
||||
because html5lib moves the parse tree around as it's being built.
|
||||
|
||||
* This TreeBuilder doesn't use different subclasses of NavigableString
|
||||
based on the name of the tag in which the string was found.
|
||||
|
||||
* You can't use a SoupStrainer to parse only part of a document.
|
||||
"""
|
||||
|
||||
NAME = "html5lib"
|
||||
|
||||
features = [NAME, PERMISSIVE, HTML_5, HTML]
|
||||
|
||||
# html5lib can tell us which line number and position in the
|
||||
# original file is the source of an element.
|
||||
TRACKS_LINE_NUMBERS = True
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding,
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
# Store the user-specified encoding for use later on.
|
||||
self.user_specified_encoding = user_specified_encoding
|
||||
|
||||
# document_declared_encoding and exclude_encodings aren't used
|
||||
# ATM because the html5lib TreeBuilder doesn't use
|
||||
# UnicodeDammit.
|
||||
if exclude_encodings:
|
||||
warnings.warn(
|
||||
"You provided a value for exclude_encoding, but the html5lib tree builder doesn't support exclude_encoding.",
|
||||
stacklevel=3
|
||||
)
|
||||
|
||||
# html5lib only parses HTML, so if it's given XML that's worth
|
||||
# noting.
|
||||
DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(
|
||||
markup, stacklevel=3
|
||||
)
|
||||
|
||||
yield (markup, None, None, False)
|
||||
|
||||
# These methods are defined by Beautiful Soup.
|
||||
def feed(self, markup):
|
||||
if self.soup.parse_only is not None:
|
||||
warnings.warn(
|
||||
"You provided a value for parse_only, but the html5lib tree builder doesn't support parse_only. The entire document will be parsed.",
|
||||
stacklevel=4
|
||||
)
|
||||
parser = html5lib.HTMLParser(tree=self.create_treebuilder)
|
||||
self.underlying_builder.parser = parser
|
||||
extra_kwargs = dict()
|
||||
if not isinstance(markup, str):
|
||||
if new_html5lib:
|
||||
extra_kwargs['override_encoding'] = self.user_specified_encoding
|
||||
else:
|
||||
extra_kwargs['encoding'] = self.user_specified_encoding
|
||||
doc = parser.parse(markup, **extra_kwargs)
|
||||
|
||||
# Set the character encoding detected by the tokenizer.
|
||||
if isinstance(markup, str):
|
||||
# We need to special-case this because html5lib sets
|
||||
# charEncoding to UTF-8 if it gets Unicode input.
|
||||
doc.original_encoding = None
|
||||
else:
|
||||
original_encoding = parser.tokenizer.stream.charEncoding[0]
|
||||
if not isinstance(original_encoding, str):
|
||||
# In 0.99999999 and up, the encoding is an html5lib
|
||||
# Encoding object. We want to use a string for compatibility
|
||||
# with other tree builders.
|
||||
original_encoding = original_encoding.name
|
||||
doc.original_encoding = original_encoding
|
||||
self.underlying_builder.parser = None
|
||||
|
||||
def create_treebuilder(self, namespaceHTMLElements):
|
||||
self.underlying_builder = TreeBuilderForHtml5lib(
|
||||
namespaceHTMLElements, self.soup,
|
||||
store_line_numbers=self.store_line_numbers
|
||||
)
|
||||
return self.underlying_builder
|
||||
|
||||
def test_fragment_to_document(self, fragment):
|
||||
"""See `TreeBuilder`."""
|
||||
return '<html><head></head><body>%s</body></html>' % fragment
|
||||
|
||||
|
||||
class TreeBuilderForHtml5lib(treebuilder_base.TreeBuilder):
|
||||
|
||||
def __init__(self, namespaceHTMLElements, soup=None,
|
||||
store_line_numbers=True, **kwargs):
|
||||
if soup:
|
||||
self.soup = soup
|
||||
else:
|
||||
from bs4 import BeautifulSoup
|
||||
# TODO: Why is the parser 'html.parser' here? To avoid an
|
||||
# infinite loop?
|
||||
self.soup = BeautifulSoup(
|
||||
"", "html.parser", store_line_numbers=store_line_numbers,
|
||||
**kwargs
|
||||
)
|
||||
# TODO: What are **kwargs exactly? Should they be passed in
|
||||
# here in addition to/instead of being passed to the BeautifulSoup
|
||||
# constructor?
|
||||
super(TreeBuilderForHtml5lib, self).__init__(namespaceHTMLElements)
|
||||
|
||||
# This will be set later to an html5lib.html5parser.HTMLParser
|
||||
# object, which we can use to track the current line number.
|
||||
self.parser = None
|
||||
self.store_line_numbers = store_line_numbers
|
||||
|
||||
def documentClass(self):
|
||||
self.soup.reset()
|
||||
return Element(self.soup, self.soup, None)
|
||||
|
||||
def insertDoctype(self, token):
|
||||
name = token["name"]
|
||||
publicId = token["publicId"]
|
||||
systemId = token["systemId"]
|
||||
|
||||
doctype = Doctype.for_name_and_ids(name, publicId, systemId)
|
||||
self.soup.object_was_parsed(doctype)
|
||||
|
||||
def elementClass(self, name, namespace):
|
||||
kwargs = {}
|
||||
if self.parser and self.store_line_numbers:
|
||||
# This represents the point immediately after the end of the
|
||||
# tag. We don't know when the tag started, but we do know
|
||||
# where it ended -- the character just before this one.
|
||||
sourceline, sourcepos = self.parser.tokenizer.stream.position()
|
||||
kwargs['sourceline'] = sourceline
|
||||
kwargs['sourcepos'] = sourcepos-1
|
||||
tag = self.soup.new_tag(name, namespace, **kwargs)
|
||||
|
||||
return Element(tag, self.soup, namespace)
|
||||
|
||||
def commentClass(self, data):
|
||||
return TextNode(Comment(data), self.soup)
|
||||
|
||||
def fragmentClass(self):
|
||||
from bs4 import BeautifulSoup
|
||||
# TODO: Why is the parser 'html.parser' here? To avoid an
|
||||
# infinite loop?
|
||||
self.soup = BeautifulSoup("", "html.parser")
|
||||
self.soup.name = "[document_fragment]"
|
||||
return Element(self.soup, self.soup, None)
|
||||
|
||||
def appendChild(self, node):
|
||||
# XXX This code is not covered by the BS4 tests.
|
||||
self.soup.append(node.element)
|
||||
|
||||
def getDocument(self):
|
||||
return self.soup
|
||||
|
||||
def getFragment(self):
|
||||
return treebuilder_base.TreeBuilder.getFragment(self).element
|
||||
|
||||
def testSerializer(self, element):
|
||||
from bs4 import BeautifulSoup
|
||||
rv = []
|
||||
doctype_re = re.compile(r'^(.*?)(?: PUBLIC "(.*?)"(?: "(.*?)")?| SYSTEM "(.*?)")?$')
|
||||
|
||||
def serializeElement(element, indent=0):
|
||||
if isinstance(element, BeautifulSoup):
|
||||
pass
|
||||
if isinstance(element, Doctype):
|
||||
m = doctype_re.match(element)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
if m.lastindex > 1:
|
||||
publicId = m.group(2) or ""
|
||||
systemId = m.group(3) or m.group(4) or ""
|
||||
rv.append("""|%s<!DOCTYPE %s "%s" "%s">""" %
|
||||
(' ' * indent, name, publicId, systemId))
|
||||
else:
|
||||
rv.append("|%s<!DOCTYPE %s>" % (' ' * indent, name))
|
||||
else:
|
||||
rv.append("|%s<!DOCTYPE >" % (' ' * indent,))
|
||||
elif isinstance(element, Comment):
|
||||
rv.append("|%s<!-- %s -->" % (' ' * indent, element))
|
||||
elif isinstance(element, NavigableString):
|
||||
rv.append("|%s\"%s\"" % (' ' * indent, element))
|
||||
else:
|
||||
if element.namespace:
|
||||
name = "%s %s" % (prefixes[element.namespace],
|
||||
element.name)
|
||||
else:
|
||||
name = element.name
|
||||
rv.append("|%s<%s>" % (' ' * indent, name))
|
||||
if element.attrs:
|
||||
attributes = []
|
||||
for name, value in list(element.attrs.items()):
|
||||
if isinstance(name, NamespacedAttribute):
|
||||
name = "%s %s" % (prefixes[name.namespace], name.name)
|
||||
if isinstance(value, list):
|
||||
value = " ".join(value)
|
||||
attributes.append((name, value))
|
||||
|
||||
for name, value in sorted(attributes):
|
||||
rv.append('|%s%s="%s"' % (' ' * (indent + 2), name, value))
|
||||
indent += 2
|
||||
for child in element.children:
|
||||
serializeElement(child, indent)
|
||||
serializeElement(element, 0)
|
||||
|
||||
return "\n".join(rv)
|
||||
|
||||
class AttrList(object):
|
||||
def __init__(self, element):
|
||||
self.element = element
|
||||
self.attrs = dict(self.element.attrs)
|
||||
def __iter__(self):
|
||||
return list(self.attrs.items()).__iter__()
|
||||
def __setitem__(self, name, value):
|
||||
# If this attribute is a multi-valued attribute for this element,
|
||||
# turn its value into a list.
|
||||
list_attr = self.element.cdata_list_attributes or {}
|
||||
if (name in list_attr.get('*', [])
|
||||
or (self.element.name in list_attr
|
||||
and name in list_attr.get(self.element.name, []))):
|
||||
# A node that is being cloned may have already undergone
|
||||
# this procedure.
|
||||
if not isinstance(value, list):
|
||||
value = nonwhitespace_re.findall(value)
|
||||
self.element[name] = value
|
||||
def items(self):
|
||||
return list(self.attrs.items())
|
||||
def keys(self):
|
||||
return list(self.attrs.keys())
|
||||
def __len__(self):
|
||||
return len(self.attrs)
|
||||
def __getitem__(self, name):
|
||||
return self.attrs[name]
|
||||
def __contains__(self, name):
|
||||
return name in list(self.attrs.keys())
|
||||
|
||||
|
||||
class Element(treebuilder_base.Node):
|
||||
def __init__(self, element, soup, namespace):
|
||||
treebuilder_base.Node.__init__(self, element.name)
|
||||
self.element = element
|
||||
self.soup = soup
|
||||
self.namespace = namespace
|
||||
|
||||
def appendChild(self, node):
|
||||
string_child = child = None
|
||||
if isinstance(node, str):
|
||||
# Some other piece of code decided to pass in a string
|
||||
# instead of creating a TextElement object to contain the
|
||||
# string.
|
||||
string_child = child = node
|
||||
elif isinstance(node, Tag):
|
||||
# Some other piece of code decided to pass in a Tag
|
||||
# instead of creating an Element object to contain the
|
||||
# Tag.
|
||||
child = node
|
||||
elif node.element.__class__ == NavigableString:
|
||||
string_child = child = node.element
|
||||
node.parent = self
|
||||
else:
|
||||
child = node.element
|
||||
node.parent = self
|
||||
|
||||
if not isinstance(child, str) and child.parent is not None:
|
||||
node.element.extract()
|
||||
|
||||
if (string_child is not None and self.element.contents
|
||||
and self.element.contents[-1].__class__ == NavigableString):
|
||||
# We are appending a string onto another string.
|
||||
# TODO This has O(n^2) performance, for input like
|
||||
# "a</a>a</a>a</a>..."
|
||||
old_element = self.element.contents[-1]
|
||||
new_element = self.soup.new_string(old_element + string_child)
|
||||
old_element.replace_with(new_element)
|
||||
self.soup._most_recent_element = new_element
|
||||
else:
|
||||
if isinstance(node, str):
|
||||
# Create a brand new NavigableString from this string.
|
||||
child = self.soup.new_string(node)
|
||||
|
||||
# Tell Beautiful Soup to act as if it parsed this element
|
||||
# immediately after the parent's last descendant. (Or
|
||||
# immediately after the parent, if it has no children.)
|
||||
if self.element.contents:
|
||||
most_recent_element = self.element._last_descendant(False)
|
||||
elif self.element.next_element is not None:
|
||||
# Something from further ahead in the parse tree is
|
||||
# being inserted into this earlier element. This is
|
||||
# very annoying because it means an expensive search
|
||||
# for the last element in the tree.
|
||||
most_recent_element = self.soup._last_descendant()
|
||||
else:
|
||||
most_recent_element = self.element
|
||||
|
||||
self.soup.object_was_parsed(
|
||||
child, parent=self.element,
|
||||
most_recent_element=most_recent_element)
|
||||
|
||||
def getAttributes(self):
|
||||
if isinstance(self.element, Comment):
|
||||
return {}
|
||||
return AttrList(self.element)
|
||||
|
||||
def setAttributes(self, attributes):
|
||||
if attributes is not None and len(attributes) > 0:
|
||||
converted_attributes = []
|
||||
for name, value in list(attributes.items()):
|
||||
if isinstance(name, tuple):
|
||||
new_name = NamespacedAttribute(*name)
|
||||
del attributes[name]
|
||||
attributes[new_name] = value
|
||||
|
||||
self.soup.builder._replace_cdata_list_attribute_values(
|
||||
self.name, attributes)
|
||||
for name, value in list(attributes.items()):
|
||||
self.element[name] = value
|
||||
|
||||
# The attributes may contain variables that need substitution.
|
||||
# Call set_up_substitutions manually.
|
||||
#
|
||||
# The Tag constructor called this method when the Tag was created,
|
||||
# but we just set/changed the attributes, so call it again.
|
||||
self.soup.builder.set_up_substitutions(self.element)
|
||||
attributes = property(getAttributes, setAttributes)
|
||||
|
||||
def insertText(self, data, insertBefore=None):
|
||||
text = TextNode(self.soup.new_string(data), self.soup)
|
||||
if insertBefore:
|
||||
self.insertBefore(text, insertBefore)
|
||||
else:
|
||||
self.appendChild(text)
|
||||
|
||||
def insertBefore(self, node, refNode):
|
||||
index = self.element.index(refNode.element)
|
||||
if (node.element.__class__ == NavigableString and self.element.contents
|
||||
and self.element.contents[index-1].__class__ == NavigableString):
|
||||
# (See comments in appendChild)
|
||||
old_node = self.element.contents[index-1]
|
||||
new_str = self.soup.new_string(old_node + node.element)
|
||||
old_node.replace_with(new_str)
|
||||
else:
|
||||
self.element.insert(index, node.element)
|
||||
node.parent = self
|
||||
|
||||
def removeChild(self, node):
|
||||
node.element.extract()
|
||||
|
||||
def reparentChildren(self, new_parent):
|
||||
"""Move all of this tag's children into another tag."""
|
||||
# print("MOVE", self.element.contents)
|
||||
# print("FROM", self.element)
|
||||
# print("TO", new_parent.element)
|
||||
|
||||
element = self.element
|
||||
new_parent_element = new_parent.element
|
||||
# Determine what this tag's next_element will be once all the children
|
||||
# are removed.
|
||||
final_next_element = element.next_sibling
|
||||
|
||||
new_parents_last_descendant = new_parent_element._last_descendant(False, False)
|
||||
if len(new_parent_element.contents) > 0:
|
||||
# The new parent already contains children. We will be
|
||||
# appending this tag's children to the end.
|
||||
new_parents_last_child = new_parent_element.contents[-1]
|
||||
new_parents_last_descendant_next_element = new_parents_last_descendant.next_element
|
||||
else:
|
||||
# The new parent contains no children.
|
||||
new_parents_last_child = None
|
||||
new_parents_last_descendant_next_element = new_parent_element.next_element
|
||||
|
||||
to_append = element.contents
|
||||
if len(to_append) > 0:
|
||||
# Set the first child's previous_element and previous_sibling
|
||||
# to elements within the new parent
|
||||
first_child = to_append[0]
|
||||
if new_parents_last_descendant is not None:
|
||||
first_child.previous_element = new_parents_last_descendant
|
||||
else:
|
||||
first_child.previous_element = new_parent_element
|
||||
first_child.previous_sibling = new_parents_last_child
|
||||
if new_parents_last_descendant is not None:
|
||||
new_parents_last_descendant.next_element = first_child
|
||||
else:
|
||||
new_parent_element.next_element = first_child
|
||||
if new_parents_last_child is not None:
|
||||
new_parents_last_child.next_sibling = first_child
|
||||
|
||||
# Find the very last element being moved. It is now the
|
||||
# parent's last descendant. It has no .next_sibling and
|
||||
# its .next_element is whatever the previous last
|
||||
# descendant had.
|
||||
last_childs_last_descendant = to_append[-1]._last_descendant(False, True)
|
||||
|
||||
last_childs_last_descendant.next_element = new_parents_last_descendant_next_element
|
||||
if new_parents_last_descendant_next_element is not None:
|
||||
# TODO: This code has no test coverage and I'm not sure
|
||||
# how to get html5lib to go through this path, but it's
|
||||
# just the other side of the previous line.
|
||||
new_parents_last_descendant_next_element.previous_element = last_childs_last_descendant
|
||||
last_childs_last_descendant.next_sibling = None
|
||||
|
||||
for child in to_append:
|
||||
child.parent = new_parent_element
|
||||
new_parent_element.contents.append(child)
|
||||
|
||||
# Now that this element has no children, change its .next_element.
|
||||
element.contents = []
|
||||
element.next_element = final_next_element
|
||||
|
||||
# print("DONE WITH MOVE")
|
||||
# print("FROM", self.element)
|
||||
# print("TO", new_parent_element)
|
||||
|
||||
def cloneNode(self):
|
||||
tag = self.soup.new_tag(self.element.name, self.namespace)
|
||||
node = Element(tag, self.soup, self.namespace)
|
||||
for key,value in self.attributes:
|
||||
node.attributes[key] = value
|
||||
return node
|
||||
|
||||
def hasContent(self):
|
||||
return self.element.contents
|
||||
|
||||
def getNameTuple(self):
|
||||
if self.namespace == None:
|
||||
return namespaces["html"], self.name
|
||||
else:
|
||||
return self.namespace, self.name
|
||||
|
||||
nameTuple = property(getNameTuple)
|
||||
|
||||
class TextNode(Element):
|
||||
def __init__(self, element, soup):
|
||||
treebuilder_base.Node.__init__(self, None)
|
||||
self.element = element
|
||||
self.soup = soup
|
||||
|
||||
def cloneNode(self):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,387 @@
|
||||
# encoding: utf-8
|
||||
"""Use the HTMLParser library to parse HTML files that aren't too bad."""
|
||||
|
||||
# Use of this source code is governed by the MIT license.
|
||||
__license__ = "MIT"
|
||||
|
||||
__all__ = [
|
||||
'HTMLParserTreeBuilder',
|
||||
]
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from bs4.element import (
|
||||
CData,
|
||||
Comment,
|
||||
Declaration,
|
||||
Doctype,
|
||||
ProcessingInstruction,
|
||||
)
|
||||
from bs4.dammit import EntitySubstitution, UnicodeDammit
|
||||
|
||||
from bs4.builder import (
|
||||
DetectsXMLParsedAsHTML,
|
||||
ParserRejectedMarkup,
|
||||
HTML,
|
||||
HTMLTreeBuilder,
|
||||
STRICT,
|
||||
)
|
||||
|
||||
|
||||
HTMLPARSER = 'html.parser'
|
||||
|
||||
class BeautifulSoupHTMLParser(HTMLParser, DetectsXMLParsedAsHTML):
|
||||
"""A subclass of the Python standard library's HTMLParser class, which
|
||||
listens for HTMLParser events and translates them into calls
|
||||
to Beautiful Soup's tree construction API.
|
||||
"""
|
||||
|
||||
# Strategies for handling duplicate attributes
|
||||
IGNORE = 'ignore'
|
||||
REPLACE = 'replace'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Constructor.
|
||||
|
||||
:param on_duplicate_attribute: A strategy for what to do if a
|
||||
tag includes the same attribute more than once. Accepted
|
||||
values are: REPLACE (replace earlier values with later
|
||||
ones, the default), IGNORE (keep the earliest value
|
||||
encountered), or a callable. A callable must take three
|
||||
arguments: the dictionary of attributes already processed,
|
||||
the name of the duplicate attribute, and the most recent value
|
||||
encountered.
|
||||
"""
|
||||
self.on_duplicate_attribute = kwargs.pop(
|
||||
'on_duplicate_attribute', self.REPLACE
|
||||
)
|
||||
HTMLParser.__init__(self, *args, **kwargs)
|
||||
|
||||
# Keep a list of empty-element tags that were encountered
|
||||
# without an explicit closing tag. If we encounter a closing tag
|
||||
# of this type, we'll associate it with one of those entries.
|
||||
#
|
||||
# This isn't a stack because we don't care about the
|
||||
# order. It's a list of closing tags we've already handled and
|
||||
# will ignore, assuming they ever show up.
|
||||
self.already_closed_empty_element = []
|
||||
|
||||
self._initialize_xml_detector()
|
||||
|
||||
def error(self, message):
|
||||
# NOTE: This method is required so long as Python 3.9 is
|
||||
# supported. The corresponding code is removed from HTMLParser
|
||||
# in 3.5, but not removed from ParserBase until 3.10.
|
||||
# https://github.com/python/cpython/issues/76025
|
||||
#
|
||||
# The original implementation turned the error into a warning,
|
||||
# but in every case I discovered, this made HTMLParser
|
||||
# immediately crash with an error message that was less
|
||||
# helpful than the warning. The new implementation makes it
|
||||
# more clear that html.parser just can't parse this
|
||||
# markup. The 3.10 implementation does the same, though it
|
||||
# raises AssertionError rather than calling a method. (We
|
||||
# catch this error and wrap it in a ParserRejectedMarkup.)
|
||||
raise ParserRejectedMarkup(message)
|
||||
|
||||
def handle_startendtag(self, name, attrs):
|
||||
"""Handle an incoming empty-element tag.
|
||||
|
||||
This is only called when the markup looks like <tag/>.
|
||||
|
||||
:param name: Name of the tag.
|
||||
:param attrs: Dictionary of the tag's attributes.
|
||||
"""
|
||||
# is_startend() tells handle_starttag not to close the tag
|
||||
# just because its name matches a known empty-element tag. We
|
||||
# know that this is an empty-element tag and we want to call
|
||||
# handle_endtag ourselves.
|
||||
tag = self.handle_starttag(name, attrs, handle_empty_element=False)
|
||||
self.handle_endtag(name)
|
||||
|
||||
def handle_starttag(self, name, attrs, handle_empty_element=True):
|
||||
"""Handle an opening tag, e.g. '<tag>'
|
||||
|
||||
:param name: Name of the tag.
|
||||
:param attrs: Dictionary of the tag's attributes.
|
||||
:param handle_empty_element: True if this tag is known to be
|
||||
an empty-element tag (i.e. there is not expected to be any
|
||||
closing tag).
|
||||
"""
|
||||
# XXX namespace
|
||||
attr_dict = {}
|
||||
for key, value in attrs:
|
||||
# Change None attribute values to the empty string
|
||||
# for consistency with the other tree builders.
|
||||
if value is None:
|
||||
value = ''
|
||||
if key in attr_dict:
|
||||
# A single attribute shows up multiple times in this
|
||||
# tag. How to handle it depends on the
|
||||
# on_duplicate_attribute setting.
|
||||
on_dupe = self.on_duplicate_attribute
|
||||
if on_dupe == self.IGNORE:
|
||||
pass
|
||||
elif on_dupe in (None, self.REPLACE):
|
||||
attr_dict[key] = value
|
||||
else:
|
||||
on_dupe(attr_dict, key, value)
|
||||
else:
|
||||
attr_dict[key] = value
|
||||
attrvalue = '""'
|
||||
#print("START", name)
|
||||
sourceline, sourcepos = self.getpos()
|
||||
tag = self.soup.handle_starttag(
|
||||
name, None, None, attr_dict, sourceline=sourceline,
|
||||
sourcepos=sourcepos
|
||||
)
|
||||
if tag and tag.is_empty_element and handle_empty_element:
|
||||
# Unlike other parsers, html.parser doesn't send separate end tag
|
||||
# events for empty-element tags. (It's handled in
|
||||
# handle_startendtag, but only if the original markup looked like
|
||||
# <tag/>.)
|
||||
#
|
||||
# So we need to call handle_endtag() ourselves. Since we
|
||||
# know the start event is identical to the end event, we
|
||||
# don't want handle_endtag() to cross off any previous end
|
||||
# events for tags of this name.
|
||||
self.handle_endtag(name, check_already_closed=False)
|
||||
|
||||
# But we might encounter an explicit closing tag for this tag
|
||||
# later on. If so, we want to ignore it.
|
||||
self.already_closed_empty_element.append(name)
|
||||
|
||||
if self._root_tag is None:
|
||||
self._root_tag_encountered(name)
|
||||
|
||||
def handle_endtag(self, name, check_already_closed=True):
|
||||
"""Handle a closing tag, e.g. '</tag>'
|
||||
|
||||
:param name: A tag name.
|
||||
:param check_already_closed: True if this tag is expected to
|
||||
be the closing portion of an empty-element tag,
|
||||
e.g. '<tag></tag>'.
|
||||
"""
|
||||
#print("END", name)
|
||||
if check_already_closed and name in self.already_closed_empty_element:
|
||||
# This is a redundant end tag for an empty-element tag.
|
||||
# We've already called handle_endtag() for it, so just
|
||||
# check it off the list.
|
||||
#print("ALREADY CLOSED", name)
|
||||
self.already_closed_empty_element.remove(name)
|
||||
else:
|
||||
self.soup.handle_endtag(name)
|
||||
|
||||
def handle_data(self, data):
|
||||
"""Handle some textual data that shows up between tags."""
|
||||
self.soup.handle_data(data)
|
||||
|
||||
def handle_charref(self, name):
|
||||
"""Handle a numeric character reference by converting it to the
|
||||
corresponding Unicode character and treating it as textual
|
||||
data.
|
||||
|
||||
:param name: Character number, possibly in hexadecimal.
|
||||
"""
|
||||
# TODO: This was originally a workaround for a bug in
|
||||
# HTMLParser. (http://bugs.python.org/issue13633) The bug has
|
||||
# been fixed, but removing this code still makes some
|
||||
# Beautiful Soup tests fail. This needs investigation.
|
||||
if name.startswith('x'):
|
||||
real_name = int(name.lstrip('x'), 16)
|
||||
elif name.startswith('X'):
|
||||
real_name = int(name.lstrip('X'), 16)
|
||||
else:
|
||||
real_name = int(name)
|
||||
|
||||
data = None
|
||||
if real_name < 256:
|
||||
# HTML numeric entities are supposed to reference Unicode
|
||||
# code points, but sometimes they reference code points in
|
||||
# some other encoding (ahem, Windows-1252). E.g. “
|
||||
# instead of É for LEFT DOUBLE QUOTATION MARK. This
|
||||
# code tries to detect this situation and compensate.
|
||||
for encoding in (self.soup.original_encoding, 'windows-1252'):
|
||||
if not encoding:
|
||||
continue
|
||||
try:
|
||||
data = bytearray([real_name]).decode(encoding)
|
||||
except UnicodeDecodeError as e:
|
||||
pass
|
||||
if not data:
|
||||
try:
|
||||
data = chr(real_name)
|
||||
except (ValueError, OverflowError) as e:
|
||||
pass
|
||||
data = data or "\N{REPLACEMENT CHARACTER}"
|
||||
self.handle_data(data)
|
||||
|
||||
def handle_entityref(self, name):
|
||||
"""Handle a named entity reference by converting it to the
|
||||
corresponding Unicode character(s) and treating it as textual
|
||||
data.
|
||||
|
||||
:param name: Name of the entity reference.
|
||||
"""
|
||||
character = EntitySubstitution.HTML_ENTITY_TO_CHARACTER.get(name)
|
||||
if character is not None:
|
||||
data = character
|
||||
else:
|
||||
# If this were XML, it would be ambiguous whether "&foo"
|
||||
# was an character entity reference with a missing
|
||||
# semicolon or the literal string "&foo". Since this is
|
||||
# HTML, we have a complete list of all character entity references,
|
||||
# and this one wasn't found, so assume it's the literal string "&foo".
|
||||
data = "&%s" % name
|
||||
self.handle_data(data)
|
||||
|
||||
def handle_comment(self, data):
|
||||
"""Handle an HTML comment.
|
||||
|
||||
:param data: The text of the comment.
|
||||
"""
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(Comment)
|
||||
|
||||
def handle_decl(self, data):
|
||||
"""Handle a DOCTYPE declaration.
|
||||
|
||||
:param data: The text of the declaration.
|
||||
"""
|
||||
self.soup.endData()
|
||||
data = data[len("DOCTYPE "):]
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(Doctype)
|
||||
|
||||
def unknown_decl(self, data):
|
||||
"""Handle a declaration of unknown type -- probably a CDATA block.
|
||||
|
||||
:param data: The text of the declaration.
|
||||
"""
|
||||
if data.upper().startswith('CDATA['):
|
||||
cls = CData
|
||||
data = data[len('CDATA['):]
|
||||
else:
|
||||
cls = Declaration
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(cls)
|
||||
|
||||
def handle_pi(self, data):
|
||||
"""Handle a processing instruction.
|
||||
|
||||
:param data: The text of the instruction.
|
||||
"""
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(data)
|
||||
self._document_might_be_xml(data)
|
||||
self.soup.endData(ProcessingInstruction)
|
||||
|
||||
|
||||
class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
"""A Beautiful soup `TreeBuilder` that uses the `HTMLParser` parser,
|
||||
found in the Python standard library.
|
||||
"""
|
||||
is_xml = False
|
||||
picklable = True
|
||||
NAME = HTMLPARSER
|
||||
features = [NAME, HTML, STRICT]
|
||||
|
||||
# The html.parser knows which line number and position in the
|
||||
# original file is the source of an element.
|
||||
TRACKS_LINE_NUMBERS = True
|
||||
|
||||
def __init__(self, parser_args=None, parser_kwargs=None, **kwargs):
|
||||
"""Constructor.
|
||||
|
||||
:param parser_args: Positional arguments to pass into
|
||||
the BeautifulSoupHTMLParser constructor, once it's
|
||||
invoked.
|
||||
:param parser_kwargs: Keyword arguments to pass into
|
||||
the BeautifulSoupHTMLParser constructor, once it's
|
||||
invoked.
|
||||
:param kwargs: Keyword arguments for the superclass constructor.
|
||||
"""
|
||||
# Some keyword arguments will be pulled out of kwargs and placed
|
||||
# into parser_kwargs.
|
||||
extra_parser_kwargs = dict()
|
||||
for arg in ('on_duplicate_attribute',):
|
||||
if arg in kwargs:
|
||||
value = kwargs.pop(arg)
|
||||
extra_parser_kwargs[arg] = value
|
||||
super(HTMLParserTreeBuilder, self).__init__(**kwargs)
|
||||
parser_args = parser_args or []
|
||||
parser_kwargs = parser_kwargs or {}
|
||||
parser_kwargs.update(extra_parser_kwargs)
|
||||
parser_kwargs['convert_charrefs'] = False
|
||||
self.parser_args = (parser_args, parser_kwargs)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
|
||||
"""Run any preliminary steps necessary to make incoming markup
|
||||
acceptable to the parser.
|
||||
|
||||
:param markup: Some markup -- probably a bytestring.
|
||||
:param user_specified_encoding: The user asked to try this encoding.
|
||||
:param document_declared_encoding: The markup itself claims to be
|
||||
in this encoding.
|
||||
:param exclude_encodings: The user asked _not_ to try any of
|
||||
these encodings.
|
||||
|
||||
:yield: A series of 4-tuples:
|
||||
(markup, encoding, declared encoding,
|
||||
has undergone character replacement)
|
||||
|
||||
Each 4-tuple represents a strategy for converting the
|
||||
document to Unicode and parsing it. Each strategy will be tried
|
||||
in turn.
|
||||
"""
|
||||
if isinstance(markup, str):
|
||||
# Parse Unicode as-is.
|
||||
yield (markup, None, None, False)
|
||||
return
|
||||
|
||||
# Ask UnicodeDammit to sniff the most likely encoding.
|
||||
|
||||
# This was provided by the end-user; treat it as a known
|
||||
# definite encoding per the algorithm laid out in the HTML5
|
||||
# spec. (See the EncodingDetector class for details.)
|
||||
known_definite_encodings = [user_specified_encoding]
|
||||
|
||||
# This was found in the document; treat it as a slightly lower-priority
|
||||
# user encoding.
|
||||
user_encodings = [document_declared_encoding]
|
||||
|
||||
try_encodings = [user_specified_encoding, document_declared_encoding]
|
||||
dammit = UnicodeDammit(
|
||||
markup,
|
||||
known_definite_encodings=known_definite_encodings,
|
||||
user_encodings=user_encodings,
|
||||
is_html=True,
|
||||
exclude_encodings=exclude_encodings
|
||||
)
|
||||
yield (dammit.markup, dammit.original_encoding,
|
||||
dammit.declared_html_encoding,
|
||||
dammit.contains_replacement_characters)
|
||||
|
||||
def feed(self, markup):
|
||||
"""Run some incoming markup through some parsing process,
|
||||
populating the `BeautifulSoup` object in self.soup.
|
||||
"""
|
||||
args, kwargs = self.parser_args
|
||||
parser = BeautifulSoupHTMLParser(*args, **kwargs)
|
||||
parser.soup = self.soup
|
||||
try:
|
||||
parser.feed(markup)
|
||||
parser.close()
|
||||
except AssertionError as e:
|
||||
# html.parser raises AssertionError in rare cases to
|
||||
# indicate a fatal problem with the markup, especially
|
||||
# when there's an error in the doctype declaration.
|
||||
raise ParserRejectedMarkup(e)
|
||||
parser.already_closed_empty_element = []
|
||||
@@ -0,0 +1,388 @@
|
||||
# Use of this source code is governed by the MIT license.
|
||||
__license__ = "MIT"
|
||||
|
||||
__all__ = [
|
||||
'LXMLTreeBuilderForXML',
|
||||
'LXMLTreeBuilder',
|
||||
]
|
||||
|
||||
try:
|
||||
from collections.abc import Callable # Python 3.6
|
||||
except ImportError as e:
|
||||
from collections import Callable
|
||||
|
||||
from io import BytesIO
|
||||
from io import StringIO
|
||||
from lxml import etree
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
Doctype,
|
||||
NamespacedAttribute,
|
||||
ProcessingInstruction,
|
||||
XMLProcessingInstruction,
|
||||
)
|
||||
from bs4.builder import (
|
||||
DetectsXMLParsedAsHTML,
|
||||
FAST,
|
||||
HTML,
|
||||
HTMLTreeBuilder,
|
||||
PERMISSIVE,
|
||||
ParserRejectedMarkup,
|
||||
TreeBuilder,
|
||||
XML)
|
||||
from bs4.dammit import EncodingDetector
|
||||
|
||||
LXML = 'lxml'
|
||||
|
||||
def _invert(d):
|
||||
"Invert a dictionary."
|
||||
return dict((v,k) for k, v in list(d.items()))
|
||||
|
||||
class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
DEFAULT_PARSER_CLASS = etree.XMLParser
|
||||
|
||||
is_xml = True
|
||||
processing_instruction_class = XMLProcessingInstruction
|
||||
|
||||
NAME = "lxml-xml"
|
||||
ALTERNATE_NAMES = ["xml"]
|
||||
|
||||
# Well, it's permissive by XML parser standards.
|
||||
features = [NAME, LXML, XML, FAST, PERMISSIVE]
|
||||
|
||||
CHUNK_SIZE = 512
|
||||
|
||||
# This namespace mapping is specified in the XML Namespace
|
||||
# standard.
|
||||
DEFAULT_NSMAPS = dict(xml='http://www.w3.org/XML/1998/namespace')
|
||||
|
||||
DEFAULT_NSMAPS_INVERTED = _invert(DEFAULT_NSMAPS)
|
||||
|
||||
# NOTE: If we parsed Element objects and looked at .sourceline,
|
||||
# we'd be able to see the line numbers from the original document.
|
||||
# But instead we build an XMLParser or HTMLParser object to serve
|
||||
# as the target of parse messages, and those messages don't include
|
||||
# line numbers.
|
||||
# See: https://bugs.launchpad.net/lxml/+bug/1846906
|
||||
|
||||
def initialize_soup(self, soup):
|
||||
"""Let the BeautifulSoup object know about the standard namespace
|
||||
mapping.
|
||||
|
||||
:param soup: A `BeautifulSoup`.
|
||||
"""
|
||||
super(LXMLTreeBuilderForXML, self).initialize_soup(soup)
|
||||
self._register_namespaces(self.DEFAULT_NSMAPS)
|
||||
|
||||
def _register_namespaces(self, mapping):
|
||||
"""Let the BeautifulSoup object know about namespaces encountered
|
||||
while parsing the document.
|
||||
|
||||
This might be useful later on when creating CSS selectors.
|
||||
|
||||
This will track (almost) all namespaces, even ones that were
|
||||
only in scope for part of the document. If two namespaces have
|
||||
the same prefix, only the first one encountered will be
|
||||
tracked. Un-prefixed namespaces are not tracked.
|
||||
|
||||
:param mapping: A dictionary mapping namespace prefixes to URIs.
|
||||
"""
|
||||
for key, value in list(mapping.items()):
|
||||
# This is 'if key' and not 'if key is not None' because we
|
||||
# don't track un-prefixed namespaces. Soupselect will
|
||||
# treat an un-prefixed namespace as the default, which
|
||||
# causes confusion in some cases.
|
||||
if key and key not in self.soup._namespaces:
|
||||
# Let the BeautifulSoup object know about a new namespace.
|
||||
# If there are multiple namespaces defined with the same
|
||||
# prefix, the first one in the document takes precedence.
|
||||
self.soup._namespaces[key] = value
|
||||
|
||||
def default_parser(self, encoding):
|
||||
"""Find the default parser for the given encoding.
|
||||
|
||||
:param encoding: A string.
|
||||
:return: Either a parser object or a class, which
|
||||
will be instantiated with default arguments.
|
||||
"""
|
||||
if self._default_parser is not None:
|
||||
return self._default_parser
|
||||
return etree.XMLParser(
|
||||
target=self, strip_cdata=False, recover=True, encoding=encoding)
|
||||
|
||||
def parser_for(self, encoding):
|
||||
"""Instantiate an appropriate parser for the given encoding.
|
||||
|
||||
:param encoding: A string.
|
||||
:return: A parser object such as an `etree.XMLParser`.
|
||||
"""
|
||||
# Use the default parser.
|
||||
parser = self.default_parser(encoding)
|
||||
|
||||
if isinstance(parser, Callable):
|
||||
# Instantiate the parser with default arguments
|
||||
parser = parser(
|
||||
target=self, strip_cdata=False, recover=True, encoding=encoding
|
||||
)
|
||||
return parser
|
||||
|
||||
def __init__(self, parser=None, empty_element_tags=None, **kwargs):
|
||||
# TODO: Issue a warning if parser is present but not a
|
||||
# callable, since that means there's no way to create new
|
||||
# parsers for different encodings.
|
||||
self._default_parser = parser
|
||||
if empty_element_tags is not None:
|
||||
self.empty_element_tags = set(empty_element_tags)
|
||||
self.soup = None
|
||||
self.nsmaps = [self.DEFAULT_NSMAPS_INVERTED]
|
||||
self.active_namespace_prefixes = [dict(self.DEFAULT_NSMAPS)]
|
||||
super(LXMLTreeBuilderForXML, self).__init__(**kwargs)
|
||||
|
||||
def _getNsTag(self, tag):
|
||||
# Split the namespace URL out of a fully-qualified lxml tag
|
||||
# name. Copied from lxml's src/lxml/sax.py.
|
||||
if tag[0] == '{':
|
||||
return tuple(tag[1:].split('}', 1))
|
||||
else:
|
||||
return (None, tag)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
exclude_encodings=None,
|
||||
document_declared_encoding=None):
|
||||
"""Run any preliminary steps necessary to make incoming markup
|
||||
acceptable to the parser.
|
||||
|
||||
lxml really wants to get a bytestring and convert it to
|
||||
Unicode itself. So instead of using UnicodeDammit to convert
|
||||
the bytestring to Unicode using different encodings, this
|
||||
implementation uses EncodingDetector to iterate over the
|
||||
encodings, and tell lxml to try to parse the document as each
|
||||
one in turn.
|
||||
|
||||
:param markup: Some markup -- hopefully a bytestring.
|
||||
:param user_specified_encoding: The user asked to try this encoding.
|
||||
:param document_declared_encoding: The markup itself claims to be
|
||||
in this encoding.
|
||||
:param exclude_encodings: The user asked _not_ to try any of
|
||||
these encodings.
|
||||
|
||||
:yield: A series of 4-tuples:
|
||||
(markup, encoding, declared encoding,
|
||||
has undergone character replacement)
|
||||
|
||||
Each 4-tuple represents a strategy for converting the
|
||||
document to Unicode and parsing it. Each strategy will be tried
|
||||
in turn.
|
||||
"""
|
||||
is_html = not self.is_xml
|
||||
if is_html:
|
||||
self.processing_instruction_class = ProcessingInstruction
|
||||
# We're in HTML mode, so if we're given XML, that's worth
|
||||
# noting.
|
||||
DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(
|
||||
markup, stacklevel=3
|
||||
)
|
||||
else:
|
||||
self.processing_instruction_class = XMLProcessingInstruction
|
||||
|
||||
if isinstance(markup, str):
|
||||
# We were given Unicode. Maybe lxml can parse Unicode on
|
||||
# this system?
|
||||
|
||||
# TODO: This is a workaround for
|
||||
# https://bugs.launchpad.net/lxml/+bug/1948551.
|
||||
# We can remove it once the upstream issue is fixed.
|
||||
if len(markup) > 0 and markup[0] == u'\N{BYTE ORDER MARK}':
|
||||
markup = markup[1:]
|
||||
yield markup, None, document_declared_encoding, False
|
||||
|
||||
if isinstance(markup, str):
|
||||
# No, apparently not. Convert the Unicode to UTF-8 and
|
||||
# tell lxml to parse it as UTF-8.
|
||||
yield (markup.encode("utf8"), "utf8",
|
||||
document_declared_encoding, False)
|
||||
|
||||
# This was provided by the end-user; treat it as a known
|
||||
# definite encoding per the algorithm laid out in the HTML5
|
||||
# spec. (See the EncodingDetector class for details.)
|
||||
known_definite_encodings = [user_specified_encoding]
|
||||
|
||||
# This was found in the document; treat it as a slightly lower-priority
|
||||
# user encoding.
|
||||
user_encodings = [document_declared_encoding]
|
||||
detector = EncodingDetector(
|
||||
markup, known_definite_encodings=known_definite_encodings,
|
||||
user_encodings=user_encodings, is_html=is_html,
|
||||
exclude_encodings=exclude_encodings
|
||||
)
|
||||
for encoding in detector.encodings:
|
||||
yield (detector.markup, encoding, document_declared_encoding, False)
|
||||
|
||||
def feed(self, markup):
|
||||
if isinstance(markup, bytes):
|
||||
markup = BytesIO(markup)
|
||||
elif isinstance(markup, str):
|
||||
markup = StringIO(markup)
|
||||
|
||||
# Call feed() at least once, even if the markup is empty,
|
||||
# or the parser won't be initialized.
|
||||
data = markup.read(self.CHUNK_SIZE)
|
||||
try:
|
||||
self.parser = self.parser_for(self.soup.original_encoding)
|
||||
self.parser.feed(data)
|
||||
while len(data) != 0:
|
||||
# Now call feed() on the rest of the data, chunk by chunk.
|
||||
data = markup.read(self.CHUNK_SIZE)
|
||||
if len(data) != 0:
|
||||
self.parser.feed(data)
|
||||
self.parser.close()
|
||||
except (UnicodeDecodeError, LookupError, etree.ParserError) as e:
|
||||
raise ParserRejectedMarkup(e)
|
||||
|
||||
def close(self):
|
||||
self.nsmaps = [self.DEFAULT_NSMAPS_INVERTED]
|
||||
|
||||
def start(self, name, attrs, nsmap={}):
|
||||
# Make sure attrs is a mutable dict--lxml may send an immutable dictproxy.
|
||||
attrs = dict(attrs)
|
||||
nsprefix = None
|
||||
# Invert each namespace map as it comes in.
|
||||
if len(nsmap) == 0 and len(self.nsmaps) > 1:
|
||||
# There are no new namespaces for this tag, but
|
||||
# non-default namespaces are in play, so we need a
|
||||
# separate tag stack to know when they end.
|
||||
self.nsmaps.append(None)
|
||||
elif len(nsmap) > 0:
|
||||
# A new namespace mapping has come into play.
|
||||
|
||||
# First, Let the BeautifulSoup object know about it.
|
||||
self._register_namespaces(nsmap)
|
||||
|
||||
# Then, add it to our running list of inverted namespace
|
||||
# mappings.
|
||||
self.nsmaps.append(_invert(nsmap))
|
||||
|
||||
# The currently active namespace prefixes have
|
||||
# changed. Calculate the new mapping so it can be stored
|
||||
# with all Tag objects created while these prefixes are in
|
||||
# scope.
|
||||
current_mapping = dict(self.active_namespace_prefixes[-1])
|
||||
current_mapping.update(nsmap)
|
||||
|
||||
# We should not track un-prefixed namespaces as we can only hold one
|
||||
# and it will be recognized as the default namespace by soupsieve,
|
||||
# which may be confusing in some situations.
|
||||
if '' in current_mapping:
|
||||
del current_mapping['']
|
||||
self.active_namespace_prefixes.append(current_mapping)
|
||||
|
||||
# Also treat the namespace mapping as a set of attributes on the
|
||||
# tag, so we can recreate it later.
|
||||
attrs = attrs.copy()
|
||||
for prefix, namespace in list(nsmap.items()):
|
||||
attribute = NamespacedAttribute(
|
||||
"xmlns", prefix, "http://www.w3.org/2000/xmlns/")
|
||||
attrs[attribute] = namespace
|
||||
|
||||
# Namespaces are in play. Find any attributes that came in
|
||||
# from lxml with namespaces attached to their names, and
|
||||
# turn then into NamespacedAttribute objects.
|
||||
new_attrs = {}
|
||||
for attr, value in list(attrs.items()):
|
||||
namespace, attr = self._getNsTag(attr)
|
||||
if namespace is None:
|
||||
new_attrs[attr] = value
|
||||
else:
|
||||
nsprefix = self._prefix_for_namespace(namespace)
|
||||
attr = NamespacedAttribute(nsprefix, attr, namespace)
|
||||
new_attrs[attr] = value
|
||||
attrs = new_attrs
|
||||
|
||||
namespace, name = self._getNsTag(name)
|
||||
nsprefix = self._prefix_for_namespace(namespace)
|
||||
self.soup.handle_starttag(
|
||||
name, namespace, nsprefix, attrs,
|
||||
namespaces=self.active_namespace_prefixes[-1]
|
||||
)
|
||||
|
||||
def _prefix_for_namespace(self, namespace):
|
||||
"""Find the currently active prefix for the given namespace."""
|
||||
if namespace is None:
|
||||
return None
|
||||
for inverted_nsmap in reversed(self.nsmaps):
|
||||
if inverted_nsmap is not None and namespace in inverted_nsmap:
|
||||
return inverted_nsmap[namespace]
|
||||
return None
|
||||
|
||||
def end(self, name):
|
||||
self.soup.endData()
|
||||
completed_tag = self.soup.tagStack[-1]
|
||||
namespace, name = self._getNsTag(name)
|
||||
nsprefix = None
|
||||
if namespace is not None:
|
||||
for inverted_nsmap in reversed(self.nsmaps):
|
||||
if inverted_nsmap is not None and namespace in inverted_nsmap:
|
||||
nsprefix = inverted_nsmap[namespace]
|
||||
break
|
||||
self.soup.handle_endtag(name, nsprefix)
|
||||
if len(self.nsmaps) > 1:
|
||||
# This tag, or one of its parents, introduced a namespace
|
||||
# mapping, so pop it off the stack.
|
||||
out_of_scope_nsmap = self.nsmaps.pop()
|
||||
|
||||
if out_of_scope_nsmap is not None:
|
||||
# This tag introduced a namespace mapping which is no
|
||||
# longer in scope. Recalculate the currently active
|
||||
# namespace prefixes.
|
||||
self.active_namespace_prefixes.pop()
|
||||
|
||||
def pi(self, target, data):
|
||||
self.soup.endData()
|
||||
data = target + ' ' + data
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(self.processing_instruction_class)
|
||||
|
||||
def data(self, content):
|
||||
self.soup.handle_data(content)
|
||||
|
||||
def doctype(self, name, pubid, system):
|
||||
self.soup.endData()
|
||||
doctype = Doctype.for_name_and_ids(name, pubid, system)
|
||||
self.soup.object_was_parsed(doctype)
|
||||
|
||||
def comment(self, content):
|
||||
"Handle comments as Comment objects."
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(content)
|
||||
self.soup.endData(Comment)
|
||||
|
||||
def test_fragment_to_document(self, fragment):
|
||||
"""See `TreeBuilder`."""
|
||||
return '<?xml version="1.0" encoding="utf-8"?>\n%s' % fragment
|
||||
|
||||
|
||||
class LXMLTreeBuilder(HTMLTreeBuilder, LXMLTreeBuilderForXML):
|
||||
|
||||
NAME = LXML
|
||||
ALTERNATE_NAMES = ["lxml-html"]
|
||||
|
||||
features = ALTERNATE_NAMES + [NAME, HTML, FAST, PERMISSIVE]
|
||||
is_xml = False
|
||||
processing_instruction_class = ProcessingInstruction
|
||||
|
||||
def default_parser(self, encoding):
|
||||
return etree.HTMLParser
|
||||
|
||||
def feed(self, markup):
|
||||
encoding = self.soup.original_encoding
|
||||
try:
|
||||
self.parser = self.parser_for(encoding)
|
||||
self.parser.feed(markup)
|
||||
self.parser.close()
|
||||
except (UnicodeDecodeError, LookupError, etree.ParserError) as e:
|
||||
raise ParserRejectedMarkup(e)
|
||||
|
||||
|
||||
def test_fragment_to_document(self, fragment):
|
||||
"""See `TreeBuilder`."""
|
||||
return '<html><body>%s</body></html>' % fragment
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Integration code for CSS selectors using Soup Sieve (pypi: soupsieve)."""
|
||||
|
||||
import warnings
|
||||
try:
|
||||
import soupsieve
|
||||
except ImportError as e:
|
||||
soupsieve = None
|
||||
warnings.warn(
|
||||
'The soupsieve package is not installed. CSS selectors cannot be used.'
|
||||
)
|
||||
|
||||
|
||||
class CSS(object):
|
||||
"""A proxy object against the soupsieve library, to simplify its
|
||||
CSS selector API.
|
||||
|
||||
Acquire this object through the .css attribute on the
|
||||
BeautifulSoup object, or on the Tag you want to use as the
|
||||
starting point for a CSS selector.
|
||||
|
||||
The main advantage of doing this is that the tag to be selected
|
||||
against doesn't need to be explicitly specified in the function
|
||||
calls, since it's already scoped to a tag.
|
||||
"""
|
||||
|
||||
def __init__(self, tag, api=soupsieve):
|
||||
"""Constructor.
|
||||
|
||||
You don't need to instantiate this class yourself; instead,
|
||||
access the .css attribute on the BeautifulSoup object, or on
|
||||
the Tag you want to use as the starting point for your CSS
|
||||
selector.
|
||||
|
||||
:param tag: All CSS selectors will use this as their starting
|
||||
point.
|
||||
|
||||
:param api: A plug-in replacement for the soupsieve module,
|
||||
designed mainly for use in tests.
|
||||
"""
|
||||
if api is None:
|
||||
raise NotImplementedError(
|
||||
"Cannot execute CSS selectors because the soupsieve package is not installed."
|
||||
)
|
||||
self.api = api
|
||||
self.tag = tag
|
||||
|
||||
def escape(self, ident):
|
||||
"""Escape a CSS identifier.
|
||||
|
||||
This is a simple wrapper around soupselect.escape(). See the
|
||||
documentation for that function for more information.
|
||||
"""
|
||||
if soupsieve is None:
|
||||
raise NotImplementedError(
|
||||
"Cannot escape CSS identifiers because the soupsieve package is not installed."
|
||||
)
|
||||
return self.api.escape(ident)
|
||||
|
||||
def _ns(self, ns, select):
|
||||
"""Normalize a dictionary of namespaces."""
|
||||
if not isinstance(select, self.api.SoupSieve) and ns is None:
|
||||
# If the selector is a precompiled pattern, it already has
|
||||
# a namespace context compiled in, which cannot be
|
||||
# replaced.
|
||||
ns = self.tag._namespaces
|
||||
return ns
|
||||
|
||||
def _rs(self, results):
|
||||
"""Normalize a list of results to a Resultset.
|
||||
|
||||
A ResultSet is more consistent with the rest of Beautiful
|
||||
Soup's API, and ResultSet.__getattr__ has a helpful error
|
||||
message if you try to treat a list of results as a single
|
||||
result (a common mistake).
|
||||
"""
|
||||
# Import here to avoid circular import
|
||||
from bs4.element import ResultSet
|
||||
return ResultSet(None, results)
|
||||
|
||||
def compile(self, select, namespaces=None, flags=0, **kwargs):
|
||||
"""Pre-compile a selector and return the compiled object.
|
||||
|
||||
:param selector: A CSS selector.
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will use the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.compile() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.compile() method.
|
||||
|
||||
:return: A precompiled selector object.
|
||||
:rtype: soupsieve.SoupSieve
|
||||
"""
|
||||
return self.api.compile(
|
||||
select, self._ns(namespaces, select), flags, **kwargs
|
||||
)
|
||||
|
||||
def select_one(self, select, namespaces=None, flags=0, **kwargs):
|
||||
"""Perform a CSS selection operation on the current Tag and return the
|
||||
first result.
|
||||
|
||||
This uses the Soup Sieve library. For more information, see
|
||||
that library's documentation for the soupsieve.select_one()
|
||||
method.
|
||||
|
||||
:param selector: A CSS selector.
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will use the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.select_one() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.select_one() method.
|
||||
|
||||
:return: A Tag, or None if the selector has no match.
|
||||
:rtype: bs4.element.Tag
|
||||
|
||||
"""
|
||||
return self.api.select_one(
|
||||
select, self.tag, self._ns(namespaces, select), flags, **kwargs
|
||||
)
|
||||
|
||||
def select(self, select, namespaces=None, limit=0, flags=0, **kwargs):
|
||||
"""Perform a CSS selection operation on the current Tag.
|
||||
|
||||
This uses the Soup Sieve library. For more information, see
|
||||
that library's documentation for the soupsieve.select()
|
||||
method.
|
||||
|
||||
:param selector: A string containing a CSS selector.
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will pass in the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param limit: After finding this number of results, stop looking.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.select() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.select() method.
|
||||
|
||||
:return: A ResultSet of Tag objects.
|
||||
:rtype: bs4.element.ResultSet
|
||||
|
||||
"""
|
||||
if limit is None:
|
||||
limit = 0
|
||||
|
||||
return self._rs(
|
||||
self.api.select(
|
||||
select, self.tag, self._ns(namespaces, select), limit, flags,
|
||||
**kwargs
|
||||
)
|
||||
)
|
||||
|
||||
def iselect(self, select, namespaces=None, limit=0, flags=0, **kwargs):
|
||||
"""Perform a CSS selection operation on the current Tag.
|
||||
|
||||
This uses the Soup Sieve library. For more information, see
|
||||
that library's documentation for the soupsieve.iselect()
|
||||
method. It is the same as select(), but it returns a generator
|
||||
instead of a list.
|
||||
|
||||
:param selector: A string containing a CSS selector.
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will pass in the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param limit: After finding this number of results, stop looking.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.iselect() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.iselect() method.
|
||||
|
||||
:return: A generator
|
||||
:rtype: types.GeneratorType
|
||||
"""
|
||||
return self.api.iselect(
|
||||
select, self.tag, self._ns(namespaces, select), limit, flags, **kwargs
|
||||
)
|
||||
|
||||
def closest(self, select, namespaces=None, flags=0, **kwargs):
|
||||
"""Find the Tag closest to this one that matches the given selector.
|
||||
|
||||
This uses the Soup Sieve library. For more information, see
|
||||
that library's documentation for the soupsieve.closest()
|
||||
method.
|
||||
|
||||
:param selector: A string containing a CSS selector.
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will pass in the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.closest() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.closest() method.
|
||||
|
||||
:return: A Tag, or None if there is no match.
|
||||
:rtype: bs4.Tag
|
||||
|
||||
"""
|
||||
return self.api.closest(
|
||||
select, self.tag, self._ns(namespaces, select), flags, **kwargs
|
||||
)
|
||||
|
||||
def match(self, select, namespaces=None, flags=0, **kwargs):
|
||||
"""Check whether this Tag matches the given CSS selector.
|
||||
|
||||
This uses the Soup Sieve library. For more information, see
|
||||
that library's documentation for the soupsieve.match()
|
||||
method.
|
||||
|
||||
:param: a CSS selector.
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will pass in the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.match() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.match() method.
|
||||
|
||||
:return: True if this Tag matches the selector; False otherwise.
|
||||
:rtype: bool
|
||||
"""
|
||||
return self.api.match(
|
||||
select, self.tag, self._ns(namespaces, select), flags, **kwargs
|
||||
)
|
||||
|
||||
def filter(self, select, namespaces=None, flags=0, **kwargs):
|
||||
"""Filter this Tag's direct children based on the given CSS selector.
|
||||
|
||||
This uses the Soup Sieve library. It works the same way as
|
||||
passing this Tag into that library's soupsieve.filter()
|
||||
method. More information, for more information see the
|
||||
documentation for soupsieve.filter().
|
||||
|
||||
:param namespaces: A dictionary mapping namespace prefixes
|
||||
used in the CSS selector to namespace URIs. By default,
|
||||
Beautiful Soup will pass in the prefixes it encountered while
|
||||
parsing the document.
|
||||
|
||||
:param flags: Flags to be passed into Soup Sieve's
|
||||
soupsieve.filter() method.
|
||||
|
||||
:param kwargs: Keyword arguments to be passed into SoupSieve's
|
||||
soupsieve.filter() method.
|
||||
|
||||
:return: A ResultSet of Tag objects.
|
||||
:rtype: bs4.element.ResultSet
|
||||
|
||||
"""
|
||||
return self._rs(
|
||||
self.api.filter(
|
||||
select, self.tag, self._ns(namespaces, select), flags, **kwargs
|
||||
)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
"""Diagnostic functions, mainly for use when doing tech support."""
|
||||
|
||||
# Use of this source code is governed by the MIT license.
|
||||
__license__ = "MIT"
|
||||
|
||||
import cProfile
|
||||
from io import BytesIO
|
||||
from html.parser import HTMLParser
|
||||
import bs4
|
||||
from bs4 import BeautifulSoup, __version__
|
||||
from bs4.builder import builder_registry
|
||||
|
||||
import os
|
||||
import pstats
|
||||
import random
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
import sys
|
||||
import cProfile
|
||||
|
||||
def diagnose(data):
|
||||
"""Diagnostic suite for isolating common problems.
|
||||
|
||||
:param data: A string containing markup that needs to be explained.
|
||||
:return: None; diagnostics are printed to standard output.
|
||||
"""
|
||||
print(("Diagnostic running on Beautiful Soup %s" % __version__))
|
||||
print(("Python version %s" % sys.version))
|
||||
|
||||
basic_parsers = ["html.parser", "html5lib", "lxml"]
|
||||
for name in basic_parsers:
|
||||
for builder in builder_registry.builders:
|
||||
if name in builder.features:
|
||||
break
|
||||
else:
|
||||
basic_parsers.remove(name)
|
||||
print((
|
||||
"I noticed that %s is not installed. Installing it may help." %
|
||||
name))
|
||||
|
||||
if 'lxml' in basic_parsers:
|
||||
basic_parsers.append("lxml-xml")
|
||||
try:
|
||||
from lxml import etree
|
||||
print(("Found lxml version %s" % ".".join(map(str,etree.LXML_VERSION))))
|
||||
except ImportError as e:
|
||||
print(
|
||||
"lxml is not installed or couldn't be imported.")
|
||||
|
||||
|
||||
if 'html5lib' in basic_parsers:
|
||||
try:
|
||||
import html5lib
|
||||
print(("Found html5lib version %s" % html5lib.__version__))
|
||||
except ImportError as e:
|
||||
print(
|
||||
"html5lib is not installed or couldn't be imported.")
|
||||
|
||||
if hasattr(data, 'read'):
|
||||
data = data.read()
|
||||
|
||||
for parser in basic_parsers:
|
||||
print(("Trying to parse your markup with %s" % parser))
|
||||
success = False
|
||||
try:
|
||||
soup = BeautifulSoup(data, features=parser)
|
||||
success = True
|
||||
except Exception as e:
|
||||
print(("%s could not parse the markup." % parser))
|
||||
traceback.print_exc()
|
||||
if success:
|
||||
print(("Here's what %s did with the markup:" % parser))
|
||||
print((soup.prettify()))
|
||||
|
||||
print(("-" * 80))
|
||||
|
||||
def lxml_trace(data, html=True, **kwargs):
|
||||
"""Print out the lxml events that occur during parsing.
|
||||
|
||||
This lets you see how lxml parses a document when no Beautiful
|
||||
Soup code is running. You can use this to determine whether
|
||||
an lxml-specific problem is in Beautiful Soup's lxml tree builders
|
||||
or in lxml itself.
|
||||
|
||||
:param data: Some markup.
|
||||
:param html: If True, markup will be parsed with lxml's HTML parser.
|
||||
if False, lxml's XML parser will be used.
|
||||
"""
|
||||
from lxml import etree
|
||||
recover = kwargs.pop('recover', True)
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf8")
|
||||
reader = BytesIO(data)
|
||||
for event, element in etree.iterparse(
|
||||
reader, html=html, recover=recover, **kwargs
|
||||
):
|
||||
print(("%s, %4s, %s" % (event, element.tag, element.text)))
|
||||
|
||||
class AnnouncingParser(HTMLParser):
|
||||
"""Subclass of HTMLParser that announces parse events, without doing
|
||||
anything else.
|
||||
|
||||
You can use this to get a picture of how html.parser sees a given
|
||||
document. The easiest way to do this is to call `htmlparser_trace`.
|
||||
"""
|
||||
|
||||
def _p(self, s):
|
||||
print(s)
|
||||
|
||||
def handle_starttag(self, name, attrs):
|
||||
self._p("%s START" % name)
|
||||
|
||||
def handle_endtag(self, name):
|
||||
self._p("%s END" % name)
|
||||
|
||||
def handle_data(self, data):
|
||||
self._p("%s DATA" % data)
|
||||
|
||||
def handle_charref(self, name):
|
||||
self._p("%s CHARREF" % name)
|
||||
|
||||
def handle_entityref(self, name):
|
||||
self._p("%s ENTITYREF" % name)
|
||||
|
||||
def handle_comment(self, data):
|
||||
self._p("%s COMMENT" % data)
|
||||
|
||||
def handle_decl(self, data):
|
||||
self._p("%s DECL" % data)
|
||||
|
||||
def unknown_decl(self, data):
|
||||
self._p("%s UNKNOWN-DECL" % data)
|
||||
|
||||
def handle_pi(self, data):
|
||||
self._p("%s PI" % data)
|
||||
|
||||
def htmlparser_trace(data):
|
||||
"""Print out the HTMLParser events that occur during parsing.
|
||||
|
||||
This lets you see how HTMLParser parses a document when no
|
||||
Beautiful Soup code is running.
|
||||
|
||||
:param data: Some markup.
|
||||
"""
|
||||
parser = AnnouncingParser()
|
||||
parser.feed(data)
|
||||
|
||||
_vowels = "aeiou"
|
||||
_consonants = "bcdfghjklmnpqrstvwxyz"
|
||||
|
||||
def rword(length=5):
|
||||
"Generate a random word-like string."
|
||||
s = ''
|
||||
for i in range(length):
|
||||
if i % 2 == 0:
|
||||
t = _consonants
|
||||
else:
|
||||
t = _vowels
|
||||
s += random.choice(t)
|
||||
return s
|
||||
|
||||
def rsentence(length=4):
|
||||
"Generate a random sentence-like string."
|
||||
return " ".join(rword(random.randint(4,9)) for i in range(length))
|
||||
|
||||
def rdoc(num_elements=1000):
|
||||
"""Randomly generate an invalid HTML document."""
|
||||
tag_names = ['p', 'div', 'span', 'i', 'b', 'script', 'table']
|
||||
elements = []
|
||||
for i in range(num_elements):
|
||||
choice = random.randint(0,3)
|
||||
if choice == 0:
|
||||
# New tag.
|
||||
tag_name = random.choice(tag_names)
|
||||
elements.append("<%s>" % tag_name)
|
||||
elif choice == 1:
|
||||
elements.append(rsentence(random.randint(1,4)))
|
||||
elif choice == 2:
|
||||
# Close a tag.
|
||||
tag_name = random.choice(tag_names)
|
||||
elements.append("</%s>" % tag_name)
|
||||
return "<html>" + "\n".join(elements) + "</html>"
|
||||
|
||||
def benchmark_parsers(num_elements=100000):
|
||||
"""Very basic head-to-head performance benchmark."""
|
||||
print(("Comparative parser benchmark on Beautiful Soup %s" % __version__))
|
||||
data = rdoc(num_elements)
|
||||
print(("Generated a large invalid HTML document (%d bytes)." % len(data)))
|
||||
|
||||
for parser in ["lxml", ["lxml", "html"], "html5lib", "html.parser"]:
|
||||
success = False
|
||||
try:
|
||||
a = time.time()
|
||||
soup = BeautifulSoup(data, parser)
|
||||
b = time.time()
|
||||
success = True
|
||||
except Exception as e:
|
||||
print(("%s could not parse the markup." % parser))
|
||||
traceback.print_exc()
|
||||
if success:
|
||||
print(("BS4+%s parsed the markup in %.2fs." % (parser, b-a)))
|
||||
|
||||
from lxml import etree
|
||||
a = time.time()
|
||||
etree.HTML(data)
|
||||
b = time.time()
|
||||
print(("Raw lxml parsed the markup in %.2fs." % (b-a)))
|
||||
|
||||
import html5lib
|
||||
parser = html5lib.HTMLParser()
|
||||
a = time.time()
|
||||
parser.parse(data)
|
||||
b = time.time()
|
||||
print(("Raw html5lib parsed the markup in %.2fs." % (b-a)))
|
||||
|
||||
def profile(num_elements=100000, parser="lxml"):
|
||||
"""Use Python's profiler on a randomly generated document."""
|
||||
filehandle = tempfile.NamedTemporaryFile()
|
||||
filename = filehandle.name
|
||||
|
||||
data = rdoc(num_elements)
|
||||
vars = dict(bs4=bs4, data=data, parser=parser)
|
||||
cProfile.runctx('bs4.BeautifulSoup(data, parser)' , vars, vars, filename)
|
||||
|
||||
stats = pstats.Stats(filename)
|
||||
# stats.strip_dirs()
|
||||
stats.sort_stats("cumulative")
|
||||
stats.print_stats('_html5lib|bs4', 50)
|
||||
|
||||
# If this file is run as a script, standard input is diagnosed.
|
||||
if __name__ == '__main__':
|
||||
diagnose(sys.stdin.read())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
from bs4.dammit import EntitySubstitution
|
||||
|
||||
class Formatter(EntitySubstitution):
|
||||
"""Describes a strategy to use when outputting a parse tree to a string.
|
||||
|
||||
Some parts of this strategy come from the distinction between
|
||||
HTML4, HTML5, and XML. Others are configurable by the user.
|
||||
|
||||
Formatters are passed in as the `formatter` argument to methods
|
||||
like `PageElement.encode`. Most people won't need to think about
|
||||
formatters, and most people who need to think about them can pass
|
||||
in one of these predefined strings as `formatter` rather than
|
||||
making a new Formatter object:
|
||||
|
||||
For HTML documents:
|
||||
* 'html' - HTML entity substitution for generic HTML documents. (default)
|
||||
* 'html5' - HTML entity substitution for HTML5 documents, as
|
||||
well as some optimizations in the way tags are rendered.
|
||||
* 'minimal' - Only make the substitutions necessary to guarantee
|
||||
valid HTML.
|
||||
* None - Do not perform any substitution. This will be faster
|
||||
but may result in invalid markup.
|
||||
|
||||
For XML documents:
|
||||
* 'html' - Entity substitution for XHTML documents.
|
||||
* 'minimal' - Only make the substitutions necessary to guarantee
|
||||
valid XML. (default)
|
||||
* None - Do not perform any substitution. This will be faster
|
||||
but may result in invalid markup.
|
||||
"""
|
||||
# Registries of XML and HTML formatters.
|
||||
XML_FORMATTERS = {}
|
||||
HTML_FORMATTERS = {}
|
||||
|
||||
HTML = 'html'
|
||||
XML = 'xml'
|
||||
|
||||
HTML_DEFAULTS = dict(
|
||||
cdata_containing_tags=set(["script", "style"]),
|
||||
)
|
||||
|
||||
def _default(self, language, value, kwarg):
|
||||
if value is not None:
|
||||
return value
|
||||
if language == self.XML:
|
||||
return set()
|
||||
return self.HTML_DEFAULTS[kwarg]
|
||||
|
||||
def __init__(
|
||||
self, language=None, entity_substitution=None,
|
||||
void_element_close_prefix='/', cdata_containing_tags=None,
|
||||
empty_attributes_are_booleans=False, indent=1,
|
||||
):
|
||||
r"""Constructor.
|
||||
|
||||
:param language: This should be Formatter.XML if you are formatting
|
||||
XML markup and Formatter.HTML if you are formatting HTML markup.
|
||||
|
||||
:param entity_substitution: A function to call to replace special
|
||||
characters with XML/HTML entities. For examples, see
|
||||
bs4.dammit.EntitySubstitution.substitute_html and substitute_xml.
|
||||
:param void_element_close_prefix: By default, void elements
|
||||
are represented as <tag/> (XML rules) rather than <tag>
|
||||
(HTML rules). To get <tag>, pass in the empty string.
|
||||
:param cdata_containing_tags: The list of tags that are defined
|
||||
as containing CDATA in this dialect. For example, in HTML,
|
||||
<script> and <style> tags are defined as containing CDATA,
|
||||
and their contents should not be formatted.
|
||||
:param blank_attributes_are_booleans: Render attributes whose value
|
||||
is the empty string as HTML-style boolean attributes.
|
||||
(Attributes whose value is None are always rendered this way.)
|
||||
|
||||
:param indent: If indent is a non-negative integer or string,
|
||||
then the contents of elements will be indented
|
||||
appropriately when pretty-printing. An indent level of 0,
|
||||
negative, or "" will only insert newlines. Using a
|
||||
positive integer indent indents that many spaces per
|
||||
level. If indent is a string (such as "\t"), that string
|
||||
is used to indent each level. The default behavior is to
|
||||
indent one space per level.
|
||||
"""
|
||||
self.language = language
|
||||
self.entity_substitution = entity_substitution
|
||||
self.void_element_close_prefix = void_element_close_prefix
|
||||
self.cdata_containing_tags = self._default(
|
||||
language, cdata_containing_tags, 'cdata_containing_tags'
|
||||
)
|
||||
self.empty_attributes_are_booleans=empty_attributes_are_booleans
|
||||
if indent is None:
|
||||
indent = 0
|
||||
if isinstance(indent, int):
|
||||
if indent < 0:
|
||||
indent = 0
|
||||
indent = ' ' * indent
|
||||
elif isinstance(indent, str):
|
||||
indent = indent
|
||||
else:
|
||||
indent = ' '
|
||||
self.indent = indent
|
||||
|
||||
def substitute(self, ns):
|
||||
"""Process a string that needs to undergo entity substitution.
|
||||
This may be a string encountered in an attribute value or as
|
||||
text.
|
||||
|
||||
:param ns: A string.
|
||||
:return: A string with certain characters replaced by named
|
||||
or numeric entities.
|
||||
"""
|
||||
if not self.entity_substitution:
|
||||
return ns
|
||||
from .element import NavigableString
|
||||
if (isinstance(ns, NavigableString)
|
||||
and ns.parent is not None
|
||||
and ns.parent.name in self.cdata_containing_tags):
|
||||
# Do nothing.
|
||||
return ns
|
||||
# Substitute.
|
||||
return self.entity_substitution(ns)
|
||||
|
||||
def attribute_value(self, value):
|
||||
"""Process the value of an attribute.
|
||||
|
||||
:param ns: A string.
|
||||
:return: A string with certain characters replaced by named
|
||||
or numeric entities.
|
||||
"""
|
||||
return self.substitute(value)
|
||||
|
||||
def attributes(self, tag):
|
||||
"""Reorder a tag's attributes however you want.
|
||||
|
||||
By default, attributes are sorted alphabetically. This makes
|
||||
behavior consistent between Python 2 and Python 3, and preserves
|
||||
backwards compatibility with older versions of Beautiful Soup.
|
||||
|
||||
If `empty_boolean_attributes` is True, then attributes whose
|
||||
values are set to the empty string will be treated as boolean
|
||||
attributes.
|
||||
"""
|
||||
if tag.attrs is None:
|
||||
return []
|
||||
return sorted(
|
||||
(k, (None if self.empty_attributes_are_booleans and v == '' else v))
|
||||
for k, v in list(tag.attrs.items())
|
||||
)
|
||||
|
||||
class HTMLFormatter(Formatter):
|
||||
"""A generic Formatter for HTML."""
|
||||
REGISTRY = {}
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HTMLFormatter, self).__init__(self.HTML, *args, **kwargs)
|
||||
|
||||
|
||||
class XMLFormatter(Formatter):
|
||||
"""A generic Formatter for XML."""
|
||||
REGISTRY = {}
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(XMLFormatter, self).__init__(self.XML, *args, **kwargs)
|
||||
|
||||
|
||||
# Set up aliases for the default formatters.
|
||||
HTMLFormatter.REGISTRY['html'] = HTMLFormatter(
|
||||
entity_substitution=EntitySubstitution.substitute_html
|
||||
)
|
||||
HTMLFormatter.REGISTRY["html5"] = HTMLFormatter(
|
||||
entity_substitution=EntitySubstitution.substitute_html,
|
||||
void_element_close_prefix=None,
|
||||
empty_attributes_are_booleans=True,
|
||||
)
|
||||
HTMLFormatter.REGISTRY["minimal"] = HTMLFormatter(
|
||||
entity_substitution=EntitySubstitution.substitute_xml
|
||||
)
|
||||
HTMLFormatter.REGISTRY[None] = HTMLFormatter(
|
||||
entity_substitution=None
|
||||
)
|
||||
XMLFormatter.REGISTRY["html"] = XMLFormatter(
|
||||
entity_substitution=EntitySubstitution.substitute_html
|
||||
)
|
||||
XMLFormatter.REGISTRY["minimal"] = XMLFormatter(
|
||||
entity_substitution=EntitySubstitution.substitute_xml
|
||||
)
|
||||
XMLFormatter.REGISTRY[None] = Formatter(
|
||||
Formatter(Formatter.XML, entity_substitution=None)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
�� � <css
|
||||
+1
@@ -0,0 +1 @@
|
||||
˙<!DOCTyPEV PUBLIC'''Đ'
|
||||
+1
@@ -0,0 +1 @@
|
||||
)<a><math><TR><a><mI><a><p><a>
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
˙ ><applet></applet><applet></applet><apple|><applet><applet><appl›„><applet><applet></applet></applet></applet></applet><applet></applet><apple>t<applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet>et><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><azplet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><plet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet></applet></applet></applet></applet></appt></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet><<meta charset=utf-8>
|
||||
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
||||
|
||||
<![
|
||||
+1
@@ -0,0 +1 @@
|
||||
-<math><sElect><mi><sElect><sElect>
|
||||
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
ñ<table><svg><html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
- ˙˙ <math><select><mi><select><select>t
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from bs4.builder import DetectsXMLParsedAsHTML
|
||||
|
||||
class TestDetectsXMLParsedAsHTML(object):
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"markup,looks_like_xml",
|
||||
[("No xml declaration", False),
|
||||
("<html>obviously HTML</html", False),
|
||||
("<?xml ><html>Actually XHTML</html>", False),
|
||||
("<?xml> < html>Tricky XHTML</html>", False),
|
||||
("<?xml ><no-html-tag>", True),
|
||||
]
|
||||
)
|
||||
def test_warn_if_markup_looks_like_xml(self, markup, looks_like_xml):
|
||||
# Test of our ability to guess at whether markup looks XML-ish
|
||||
# _and_ not HTML-ish.
|
||||
with patch('bs4.builder.DetectsXMLParsedAsHTML._warn') as mock:
|
||||
for data in markup, markup.encode('utf8'):
|
||||
result = DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(
|
||||
data
|
||||
)
|
||||
assert result == looks_like_xml
|
||||
if looks_like_xml:
|
||||
assert mock.called
|
||||
else:
|
||||
assert not mock.called
|
||||
mock.reset_mock()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests of the builder registry."""
|
||||
|
||||
import pytest
|
||||
import warnings
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.builder import (
|
||||
builder_registry as registry,
|
||||
HTMLParserTreeBuilder,
|
||||
TreeBuilderRegistry,
|
||||
)
|
||||
|
||||
from . import (
|
||||
HTML5LIB_PRESENT,
|
||||
LXML_PRESENT,
|
||||
)
|
||||
|
||||
if HTML5LIB_PRESENT:
|
||||
from bs4.builder import HTML5TreeBuilder
|
||||
|
||||
if LXML_PRESENT:
|
||||
from bs4.builder import (
|
||||
LXMLTreeBuilderForXML,
|
||||
LXMLTreeBuilder,
|
||||
)
|
||||
|
||||
|
||||
# TODO: Split out the lxml and html5lib tests into their own classes
|
||||
# and gate with pytest.mark.skipIf.
|
||||
class TestBuiltInRegistry(object):
|
||||
"""Test the built-in registry with the default builders registered."""
|
||||
|
||||
def test_combination(self):
|
||||
assert registry.lookup('strict', 'html') == HTMLParserTreeBuilder
|
||||
if LXML_PRESENT:
|
||||
assert registry.lookup('fast', 'html') == LXMLTreeBuilder
|
||||
assert registry.lookup('permissive', 'xml') == LXMLTreeBuilderForXML
|
||||
if HTML5LIB_PRESENT:
|
||||
assert registry.lookup('html5lib', 'html') == HTML5TreeBuilder
|
||||
|
||||
def test_lookup_by_markup_type(self):
|
||||
if LXML_PRESENT:
|
||||
assert registry.lookup('html') == LXMLTreeBuilder
|
||||
assert registry.lookup('xml') == LXMLTreeBuilderForXML
|
||||
else:
|
||||
assert registry.lookup('xml') == None
|
||||
if HTML5LIB_PRESENT:
|
||||
assert registry.lookup('html') == HTML5TreeBuilder
|
||||
else:
|
||||
assert registry.lookup('html') == HTMLParserTreeBuilder
|
||||
|
||||
def test_named_library(self):
|
||||
if LXML_PRESENT:
|
||||
assert registry.lookup('lxml', 'xml') == LXMLTreeBuilderForXML
|
||||
assert registry.lookup('lxml', 'html') == LXMLTreeBuilder
|
||||
if HTML5LIB_PRESENT:
|
||||
assert registry.lookup('html5lib') == HTML5TreeBuilder
|
||||
|
||||
assert registry.lookup('html.parser') == HTMLParserTreeBuilder
|
||||
|
||||
def test_beautifulsoup_constructor_does_lookup(self):
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
# This will create a warning about not explicitly
|
||||
# specifying a parser, but we'll ignore it.
|
||||
|
||||
# You can pass in a string.
|
||||
BeautifulSoup("", features="html")
|
||||
# Or a list of strings.
|
||||
BeautifulSoup("", features=["html", "fast"])
|
||||
pass
|
||||
|
||||
# You'll get an exception if BS can't find an appropriate
|
||||
# builder.
|
||||
with pytest.raises(ValueError):
|
||||
BeautifulSoup("", features="no-such-feature")
|
||||
|
||||
class TestRegistry(object):
|
||||
"""Test the TreeBuilderRegistry class in general."""
|
||||
|
||||
def setup_method(self):
|
||||
self.registry = TreeBuilderRegistry()
|
||||
|
||||
def builder_for_features(self, *feature_list):
|
||||
cls = type('Builder_' + '_'.join(feature_list),
|
||||
(object,), {'features' : feature_list})
|
||||
|
||||
self.registry.register(cls)
|
||||
return cls
|
||||
|
||||
def test_register_with_no_features(self):
|
||||
builder = self.builder_for_features()
|
||||
|
||||
# Since the builder advertises no features, you can't find it
|
||||
# by looking up features.
|
||||
assert self.registry.lookup('foo') is None
|
||||
|
||||
# But you can find it by doing a lookup with no features, if
|
||||
# this happens to be the only registered builder.
|
||||
assert self.registry.lookup() == builder
|
||||
|
||||
def test_register_with_features_makes_lookup_succeed(self):
|
||||
builder = self.builder_for_features('foo', 'bar')
|
||||
assert self.registry.lookup('foo') is builder
|
||||
assert self.registry.lookup('bar') is builder
|
||||
|
||||
def test_lookup_fails_when_no_builder_implements_feature(self):
|
||||
builder = self.builder_for_features('foo', 'bar')
|
||||
assert self.registry.lookup('baz') is None
|
||||
|
||||
def test_lookup_gets_most_recent_registration_when_no_feature_specified(self):
|
||||
builder1 = self.builder_for_features('foo')
|
||||
builder2 = self.builder_for_features('bar')
|
||||
assert self.registry.lookup() == builder2
|
||||
|
||||
def test_lookup_fails_when_no_tree_builders_registered(self):
|
||||
assert self.registry.lookup() is None
|
||||
|
||||
def test_lookup_gets_most_recent_builder_supporting_all_features(self):
|
||||
has_one = self.builder_for_features('foo')
|
||||
has_the_other = self.builder_for_features('bar')
|
||||
has_both_early = self.builder_for_features('foo', 'bar', 'baz')
|
||||
has_both_late = self.builder_for_features('foo', 'bar', 'quux')
|
||||
lacks_one = self.builder_for_features('bar')
|
||||
has_the_other = self.builder_for_features('foo')
|
||||
|
||||
# There are two builders featuring 'foo' and 'bar', but
|
||||
# the one that also features 'quux' was registered later.
|
||||
assert self.registry.lookup('foo', 'bar') == has_both_late
|
||||
|
||||
# There is only one builder featuring 'foo', 'bar', and 'baz'.
|
||||
assert self.registry.lookup('foo', 'bar', 'baz') == has_both_early
|
||||
|
||||
def test_lookup_fails_when_cannot_reconcile_requested_features(self):
|
||||
builder1 = self.builder_for_features('foo', 'bar')
|
||||
builder2 = self.builder_for_features('foo', 'baz')
|
||||
assert self.registry.lookup('bar', 'baz') is None
|
||||
@@ -0,0 +1,487 @@
|
||||
import pytest
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bs4 import (
|
||||
CSS,
|
||||
BeautifulSoup,
|
||||
ResultSet,
|
||||
)
|
||||
|
||||
from . import (
|
||||
SoupTest,
|
||||
SOUP_SIEVE_PRESENT,
|
||||
)
|
||||
|
||||
if SOUP_SIEVE_PRESENT:
|
||||
from soupsieve import SelectorSyntaxError
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SOUP_SIEVE_PRESENT, reason="Soup Sieve not installed")
|
||||
class TestCSSSelectors(SoupTest):
|
||||
"""Test basic CSS selector functionality.
|
||||
|
||||
This functionality is implemented in soupsieve, which has a much
|
||||
more comprehensive test suite, so this is basically an extra check
|
||||
that soupsieve works as expected.
|
||||
"""
|
||||
|
||||
HTML = """
|
||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
|
||||
"http://www.w3.org/TR/html4/strict.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<title>The title</title>
|
||||
<link rel="stylesheet" href="blah.css" type="text/css" id="l1">
|
||||
</head>
|
||||
<body>
|
||||
<custom-dashed-tag class="dashed" id="dash1">Hello there.</custom-dashed-tag>
|
||||
<div id="main" class="fancy">
|
||||
<div id="inner">
|
||||
<h1 id="header1">An H1</h1>
|
||||
<p>Some text</p>
|
||||
<p class="onep" id="p1">Some more text</p>
|
||||
<h2 id="header2">An H2</h2>
|
||||
<p class="class1 class2 class3" id="pmulti">Another</p>
|
||||
<a href="http://bob.example.org/" rel="friend met" id="bob">Bob</a>
|
||||
<h2 id="header3">Another H2</h2>
|
||||
<a id="me" href="http://simonwillison.net/" rel="me">me</a>
|
||||
<span class="s1">
|
||||
<a href="#" id="s1a1">span1a1</a>
|
||||
<a href="#" id="s1a2">span1a2 <span id="s1a2s1">test</span></a>
|
||||
<span class="span2">
|
||||
<a href="#" id="s2a1">span2a1</a>
|
||||
</span>
|
||||
<span class="span3"></span>
|
||||
<custom-dashed-tag class="dashed" id="dash2"/>
|
||||
<div data-tag="dashedvalue" id="data1"/>
|
||||
</span>
|
||||
</div>
|
||||
<x id="xid">
|
||||
<z id="zida"/>
|
||||
<z id="zidab"/>
|
||||
<z id="zidac"/>
|
||||
</x>
|
||||
<y id="yid">
|
||||
<z id="zidb"/>
|
||||
</y>
|
||||
<p lang="en" id="lang-en">English</p>
|
||||
<p lang="en-gb" id="lang-en-gb">English UK</p>
|
||||
<p lang="en-us" id="lang-en-us">English US</p>
|
||||
<p lang="fr" id="lang-fr">French</p>
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
</div>
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.soup = BeautifulSoup(self.HTML, 'html.parser')
|
||||
|
||||
def assert_selects(self, selector, expected_ids, **kwargs):
|
||||
results = self.soup.select(selector, **kwargs)
|
||||
assert isinstance(results, ResultSet)
|
||||
el_ids = [el['id'] for el in results]
|
||||
el_ids.sort()
|
||||
expected_ids.sort()
|
||||
assert expected_ids == el_ids, "Selector %s, expected [%s], got [%s]" % (
|
||||
selector, ', '.join(expected_ids), ', '.join(el_ids)
|
||||
)
|
||||
|
||||
assertSelect = assert_selects
|
||||
|
||||
def assert_select_multiple(self, *tests):
|
||||
for selector, expected_ids in tests:
|
||||
self.assert_selects(selector, expected_ids)
|
||||
|
||||
def test_precompiled(self):
|
||||
sel = self.soup.css.compile('div')
|
||||
|
||||
els = self.soup.select(sel)
|
||||
assert len(els) == 4
|
||||
for div in els:
|
||||
assert div.name == 'div'
|
||||
|
||||
el = self.soup.select_one(sel)
|
||||
assert 'main' == el['id']
|
||||
|
||||
def test_one_tag_one(self):
|
||||
els = self.soup.select('title')
|
||||
assert len(els) == 1
|
||||
assert els[0].name == 'title'
|
||||
assert els[0].contents == ['The title']
|
||||
|
||||
def test_one_tag_many(self):
|
||||
els = self.soup.select('div')
|
||||
assert len(els) == 4
|
||||
for div in els:
|
||||
assert div.name == 'div'
|
||||
|
||||
el = self.soup.select_one('div')
|
||||
assert 'main' == el['id']
|
||||
|
||||
def test_select_one_returns_none_if_no_match(self):
|
||||
match = self.soup.select_one('nonexistenttag')
|
||||
assert None == match
|
||||
|
||||
|
||||
def test_tag_in_tag_one(self):
|
||||
els = self.soup.select('div div')
|
||||
self.assert_selects('div div', ['inner', 'data1'])
|
||||
|
||||
def test_tag_in_tag_many(self):
|
||||
for selector in ('html div', 'html body div', 'body div'):
|
||||
self.assert_selects(selector, ['data1', 'main', 'inner', 'footer'])
|
||||
|
||||
|
||||
def test_limit(self):
|
||||
self.assert_selects('html div', ['main'], limit=1)
|
||||
self.assert_selects('html body div', ['inner', 'main'], limit=2)
|
||||
self.assert_selects('body div', ['data1', 'main', 'inner', 'footer'],
|
||||
limit=10)
|
||||
|
||||
def test_tag_no_match(self):
|
||||
assert len(self.soup.select('del')) == 0
|
||||
|
||||
def test_invalid_tag(self):
|
||||
with pytest.raises(SelectorSyntaxError):
|
||||
self.soup.select('tag%t')
|
||||
|
||||
def test_select_dashed_tag_ids(self):
|
||||
self.assert_selects('custom-dashed-tag', ['dash1', 'dash2'])
|
||||
|
||||
def test_select_dashed_by_id(self):
|
||||
dashed = self.soup.select('custom-dashed-tag[id=\"dash2\"]')
|
||||
assert dashed[0].name == 'custom-dashed-tag'
|
||||
assert dashed[0]['id'] == 'dash2'
|
||||
|
||||
def test_dashed_tag_text(self):
|
||||
assert self.soup.select('body > custom-dashed-tag')[0].text == 'Hello there.'
|
||||
|
||||
def test_select_dashed_matches_find_all(self):
|
||||
assert self.soup.select('custom-dashed-tag') == self.soup.find_all('custom-dashed-tag')
|
||||
|
||||
def test_header_tags(self):
|
||||
self.assert_select_multiple(
|
||||
('h1', ['header1']),
|
||||
('h2', ['header2', 'header3']),
|
||||
)
|
||||
|
||||
def test_class_one(self):
|
||||
for selector in ('.onep', 'p.onep', 'html p.onep'):
|
||||
els = self.soup.select(selector)
|
||||
assert len(els) == 1
|
||||
assert els[0].name == 'p'
|
||||
assert els[0]['class'] == ['onep']
|
||||
|
||||
def test_class_mismatched_tag(self):
|
||||
els = self.soup.select('div.onep')
|
||||
assert len(els) == 0
|
||||
|
||||
def test_one_id(self):
|
||||
for selector in ('div#inner', '#inner', 'div div#inner'):
|
||||
self.assert_selects(selector, ['inner'])
|
||||
|
||||
def test_bad_id(self):
|
||||
els = self.soup.select('#doesnotexist')
|
||||
assert len(els) == 0
|
||||
|
||||
def test_items_in_id(self):
|
||||
els = self.soup.select('div#inner p')
|
||||
assert len(els) == 3
|
||||
for el in els:
|
||||
assert el.name == 'p'
|
||||
assert els[1]['class'] == ['onep']
|
||||
assert not els[0].has_attr('class')
|
||||
|
||||
def test_a_bunch_of_emptys(self):
|
||||
for selector in ('div#main del', 'div#main div.oops', 'div div#main'):
|
||||
assert len(self.soup.select(selector)) == 0
|
||||
|
||||
def test_multi_class_support(self):
|
||||
for selector in ('.class1', 'p.class1', '.class2', 'p.class2',
|
||||
'.class3', 'p.class3', 'html p.class2', 'div#inner .class2'):
|
||||
self.assert_selects(selector, ['pmulti'])
|
||||
|
||||
def test_multi_class_selection(self):
|
||||
for selector in ('.class1.class3', '.class3.class2',
|
||||
'.class1.class2.class3'):
|
||||
self.assert_selects(selector, ['pmulti'])
|
||||
|
||||
def test_child_selector(self):
|
||||
self.assert_selects('.s1 > a', ['s1a1', 's1a2'])
|
||||
self.assert_selects('.s1 > a span', ['s1a2s1'])
|
||||
|
||||
def test_child_selector_id(self):
|
||||
self.assert_selects('.s1 > a#s1a2 span', ['s1a2s1'])
|
||||
|
||||
def test_attribute_equals(self):
|
||||
self.assert_select_multiple(
|
||||
('p[class="onep"]', ['p1']),
|
||||
('p[id="p1"]', ['p1']),
|
||||
('[class="onep"]', ['p1']),
|
||||
('[id="p1"]', ['p1']),
|
||||
('link[rel="stylesheet"]', ['l1']),
|
||||
('link[type="text/css"]', ['l1']),
|
||||
('link[href="blah.css"]', ['l1']),
|
||||
('link[href="no-blah.css"]', []),
|
||||
('[rel="stylesheet"]', ['l1']),
|
||||
('[type="text/css"]', ['l1']),
|
||||
('[href="blah.css"]', ['l1']),
|
||||
('[href="no-blah.css"]', []),
|
||||
('p[href="no-blah.css"]', []),
|
||||
('[href="no-blah.css"]', []),
|
||||
)
|
||||
|
||||
def test_attribute_tilde(self):
|
||||
self.assert_select_multiple(
|
||||
('p[class~="class1"]', ['pmulti']),
|
||||
('p[class~="class2"]', ['pmulti']),
|
||||
('p[class~="class3"]', ['pmulti']),
|
||||
('[class~="class1"]', ['pmulti']),
|
||||
('[class~="class2"]', ['pmulti']),
|
||||
('[class~="class3"]', ['pmulti']),
|
||||
('a[rel~="friend"]', ['bob']),
|
||||
('a[rel~="met"]', ['bob']),
|
||||
('[rel~="friend"]', ['bob']),
|
||||
('[rel~="met"]', ['bob']),
|
||||
)
|
||||
|
||||
def test_attribute_startswith(self):
|
||||
self.assert_select_multiple(
|
||||
('[rel^="style"]', ['l1']),
|
||||
('link[rel^="style"]', ['l1']),
|
||||
('notlink[rel^="notstyle"]', []),
|
||||
('[rel^="notstyle"]', []),
|
||||
('link[rel^="notstyle"]', []),
|
||||
('link[href^="bla"]', ['l1']),
|
||||
('a[href^="http://"]', ['bob', 'me']),
|
||||
('[href^="http://"]', ['bob', 'me']),
|
||||
('[id^="p"]', ['pmulti', 'p1']),
|
||||
('[id^="m"]', ['me', 'main']),
|
||||
('div[id^="m"]', ['main']),
|
||||
('a[id^="m"]', ['me']),
|
||||
('div[data-tag^="dashed"]', ['data1'])
|
||||
)
|
||||
|
||||
def test_attribute_endswith(self):
|
||||
self.assert_select_multiple(
|
||||
('[href$=".css"]', ['l1']),
|
||||
('link[href$=".css"]', ['l1']),
|
||||
('link[id$="1"]', ['l1']),
|
||||
('[id$="1"]', ['data1', 'l1', 'p1', 'header1', 's1a1', 's2a1', 's1a2s1', 'dash1']),
|
||||
('div[id$="1"]', ['data1']),
|
||||
('[id$="noending"]', []),
|
||||
)
|
||||
|
||||
def test_attribute_contains(self):
|
||||
self.assert_select_multiple(
|
||||
# From test_attribute_startswith
|
||||
('[rel*="style"]', ['l1']),
|
||||
('link[rel*="style"]', ['l1']),
|
||||
('notlink[rel*="notstyle"]', []),
|
||||
('[rel*="notstyle"]', []),
|
||||
('link[rel*="notstyle"]', []),
|
||||
('link[href*="bla"]', ['l1']),
|
||||
('[href*="http://"]', ['bob', 'me']),
|
||||
('[id*="p"]', ['pmulti', 'p1']),
|
||||
('div[id*="m"]', ['main']),
|
||||
('a[id*="m"]', ['me']),
|
||||
# From test_attribute_endswith
|
||||
('[href*=".css"]', ['l1']),
|
||||
('link[href*=".css"]', ['l1']),
|
||||
('link[id*="1"]', ['l1']),
|
||||
('[id*="1"]', ['data1', 'l1', 'p1', 'header1', 's1a1', 's1a2', 's2a1', 's1a2s1', 'dash1']),
|
||||
('div[id*="1"]', ['data1']),
|
||||
('[id*="noending"]', []),
|
||||
# New for this test
|
||||
('[href*="."]', ['bob', 'me', 'l1']),
|
||||
('a[href*="."]', ['bob', 'me']),
|
||||
('link[href*="."]', ['l1']),
|
||||
('div[id*="n"]', ['main', 'inner']),
|
||||
('div[id*="nn"]', ['inner']),
|
||||
('div[data-tag*="edval"]', ['data1'])
|
||||
)
|
||||
|
||||
def test_attribute_exact_or_hypen(self):
|
||||
self.assert_select_multiple(
|
||||
('p[lang|="en"]', ['lang-en', 'lang-en-gb', 'lang-en-us']),
|
||||
('[lang|="en"]', ['lang-en', 'lang-en-gb', 'lang-en-us']),
|
||||
('p[lang|="fr"]', ['lang-fr']),
|
||||
('p[lang|="gb"]', []),
|
||||
)
|
||||
|
||||
def test_attribute_exists(self):
|
||||
self.assert_select_multiple(
|
||||
('[rel]', ['l1', 'bob', 'me']),
|
||||
('link[rel]', ['l1']),
|
||||
('a[rel]', ['bob', 'me']),
|
||||
('[lang]', ['lang-en', 'lang-en-gb', 'lang-en-us', 'lang-fr']),
|
||||
('p[class]', ['p1', 'pmulti']),
|
||||
('[blah]', []),
|
||||
('p[blah]', []),
|
||||
('div[data-tag]', ['data1'])
|
||||
)
|
||||
|
||||
def test_quoted_space_in_selector_name(self):
|
||||
html = """<div style="display: wrong">nope</div>
|
||||
<div style="display: right">yes</div>
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
[chosen] = soup.select('div[style="display: right"]')
|
||||
assert "yes" == chosen.string
|
||||
|
||||
def test_unsupported_pseudoclass(self):
|
||||
with pytest.raises(NotImplementedError):
|
||||
self.soup.select("a:no-such-pseudoclass")
|
||||
|
||||
with pytest.raises(SelectorSyntaxError):
|
||||
self.soup.select("a:nth-of-type(a)")
|
||||
|
||||
def test_nth_of_type(self):
|
||||
# Try to select first paragraph
|
||||
els = self.soup.select('div#inner p:nth-of-type(1)')
|
||||
assert len(els) == 1
|
||||
assert els[0].string == 'Some text'
|
||||
|
||||
# Try to select third paragraph
|
||||
els = self.soup.select('div#inner p:nth-of-type(3)')
|
||||
assert len(els) == 1
|
||||
assert els[0].string == 'Another'
|
||||
|
||||
# Try to select (non-existent!) fourth paragraph
|
||||
els = self.soup.select('div#inner p:nth-of-type(4)')
|
||||
assert len(els) == 0
|
||||
|
||||
# Zero will select no tags.
|
||||
els = self.soup.select('div p:nth-of-type(0)')
|
||||
assert len(els) == 0
|
||||
|
||||
def test_nth_of_type_direct_descendant(self):
|
||||
els = self.soup.select('div#inner > p:nth-of-type(1)')
|
||||
assert len(els) == 1
|
||||
assert els[0].string == 'Some text'
|
||||
|
||||
def test_id_child_selector_nth_of_type(self):
|
||||
self.assert_selects('#inner > p:nth-of-type(2)', ['p1'])
|
||||
|
||||
def test_select_on_element(self):
|
||||
# Other tests operate on the tree; this operates on an element
|
||||
# within the tree.
|
||||
inner = self.soup.find("div", id="main")
|
||||
selected = inner.select("div")
|
||||
# The <div id="inner"> tag was selected. The <div id="footer">
|
||||
# tag was not.
|
||||
self.assert_selects_ids(selected, ['inner', 'data1'])
|
||||
|
||||
def test_overspecified_child_id(self):
|
||||
self.assert_selects(".fancy #inner", ['inner'])
|
||||
self.assert_selects(".normal #inner", [])
|
||||
|
||||
def test_adjacent_sibling_selector(self):
|
||||
self.assert_selects('#p1 + h2', ['header2'])
|
||||
self.assert_selects('#p1 + h2 + p', ['pmulti'])
|
||||
self.assert_selects('#p1 + #header2 + .class1', ['pmulti'])
|
||||
assert [] == self.soup.select('#p1 + p')
|
||||
|
||||
def test_general_sibling_selector(self):
|
||||
self.assert_selects('#p1 ~ h2', ['header2', 'header3'])
|
||||
self.assert_selects('#p1 ~ #header2', ['header2'])
|
||||
self.assert_selects('#p1 ~ h2 + a', ['me'])
|
||||
self.assert_selects('#p1 ~ h2 + [rel="me"]', ['me'])
|
||||
assert [] == self.soup.select('#inner ~ h2')
|
||||
|
||||
def test_dangling_combinator(self):
|
||||
with pytest.raises(SelectorSyntaxError):
|
||||
self.soup.select('h1 >')
|
||||
|
||||
def test_sibling_combinator_wont_select_same_tag_twice(self):
|
||||
self.assert_selects('p[lang] ~ p', ['lang-en-gb', 'lang-en-us', 'lang-fr'])
|
||||
|
||||
# Test the selector grouping operator (the comma)
|
||||
def test_multiple_select(self):
|
||||
self.assert_selects('x, y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_with_no_space(self):
|
||||
self.assert_selects('x,y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_with_more_space(self):
|
||||
self.assert_selects('x, y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_duplicated(self):
|
||||
self.assert_selects('x, x', ['xid'])
|
||||
|
||||
def test_multiple_select_sibling(self):
|
||||
self.assert_selects('x, y ~ p[lang=fr]', ['xid', 'lang-fr'])
|
||||
|
||||
def test_multiple_select_tag_and_direct_descendant(self):
|
||||
self.assert_selects('x, y > z', ['xid', 'zidb'])
|
||||
|
||||
def test_multiple_select_direct_descendant_and_tags(self):
|
||||
self.assert_selects('div > x, y, z', ['xid', 'yid', 'zida', 'zidb', 'zidab', 'zidac'])
|
||||
|
||||
def test_multiple_select_indirect_descendant(self):
|
||||
self.assert_selects('div x,y, z', ['xid', 'yid', 'zida', 'zidb', 'zidab', 'zidac'])
|
||||
|
||||
def test_invalid_multiple_select(self):
|
||||
with pytest.raises(SelectorSyntaxError):
|
||||
self.soup.select(',x, y')
|
||||
with pytest.raises(SelectorSyntaxError):
|
||||
self.soup.select('x,,y')
|
||||
|
||||
def test_multiple_select_attrs(self):
|
||||
self.assert_selects('p[lang=en], p[lang=en-gb]', ['lang-en', 'lang-en-gb'])
|
||||
|
||||
def test_multiple_select_ids(self):
|
||||
self.assert_selects('x, y > z[id=zida], z[id=zidab], z[id=zidb]', ['xid', 'zidb', 'zidab'])
|
||||
|
||||
def test_multiple_select_nested(self):
|
||||
self.assert_selects('body > div > x, y > z', ['xid', 'zidb'])
|
||||
|
||||
def test_select_duplicate_elements(self):
|
||||
# When markup contains duplicate elements, a multiple select
|
||||
# will find all of them.
|
||||
markup = '<div class="c1"/><div class="c2"/><div class="c1"/>'
|
||||
soup = BeautifulSoup(markup, 'html.parser')
|
||||
selected = soup.select(".c1, .c2")
|
||||
assert 3 == len(selected)
|
||||
|
||||
# Verify that find_all finds the same elements, though because
|
||||
# of an implementation detail it finds them in a different
|
||||
# order.
|
||||
for element in soup.find_all(class_=['c1', 'c2']):
|
||||
assert element in selected
|
||||
|
||||
def test_closest(self):
|
||||
inner = self.soup.find("div", id="inner")
|
||||
closest = inner.css.closest("div[id=main]")
|
||||
assert closest == self.soup.find("div", id="main")
|
||||
|
||||
def test_match(self):
|
||||
inner = self.soup.find("div", id="inner")
|
||||
main = self.soup.find("div", id="main")
|
||||
assert inner.css.match("div[id=main]") == False
|
||||
assert main.css.match("div[id=main]") == True
|
||||
|
||||
def test_iselect(self):
|
||||
gen = self.soup.css.iselect("h2")
|
||||
assert isinstance(gen, types.GeneratorType)
|
||||
[header2, header3] = gen
|
||||
assert header2['id'] == 'header2'
|
||||
assert header3['id'] == 'header3'
|
||||
|
||||
def test_filter(self):
|
||||
inner = self.soup.find("div", id="inner")
|
||||
results = inner.css.filter("h2")
|
||||
assert len(inner.css.filter("h2")) == 2
|
||||
|
||||
results = inner.css.filter("h2[id=header3]")
|
||||
assert isinstance(results, ResultSet)
|
||||
[result] = results
|
||||
assert result['id'] == 'header3'
|
||||
|
||||
def test_escape(self):
|
||||
m = self.soup.css.escape
|
||||
assert m(".foo#bar") == '\\.foo\\#bar'
|
||||
assert m("()[]{}") == '\\(\\)\\[\\]\\{\\}'
|
||||
assert m(".foo") == self.soup.css.escape(".foo")
|
||||
@@ -0,0 +1,370 @@
|
||||
# encoding: utf-8
|
||||
import pytest
|
||||
import logging
|
||||
import bs4
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.dammit import (
|
||||
EntitySubstitution,
|
||||
EncodingDetector,
|
||||
UnicodeDammit,
|
||||
)
|
||||
|
||||
class TestUnicodeDammit(object):
|
||||
"""Standalone tests of UnicodeDammit."""
|
||||
|
||||
def test_unicode_input(self):
|
||||
markup = "I'm already Unicode! \N{SNOWMAN}"
|
||||
dammit = UnicodeDammit(markup)
|
||||
assert dammit.unicode_markup == markup
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"smart_quotes_to,expect_converted",
|
||||
[(None, "\u2018\u2019\u201c\u201d"),
|
||||
("xml", "‘’“”"),
|
||||
("html", "‘’“”"),
|
||||
("ascii", "''" + '""'),
|
||||
]
|
||||
)
|
||||
def test_smart_quotes_to(self, smart_quotes_to, expect_converted):
|
||||
"""Verify the functionality of the smart_quotes_to argument
|
||||
to the UnicodeDammit constructor."""
|
||||
markup = b"<foo>\x91\x92\x93\x94</foo>"
|
||||
converted = UnicodeDammit(
|
||||
markup, known_definite_encodings=["windows-1252"],
|
||||
smart_quotes_to=smart_quotes_to
|
||||
).unicode_markup
|
||||
assert converted == "<foo>{}</foo>".format(expect_converted)
|
||||
|
||||
def test_detect_utf8(self):
|
||||
utf8 = b"Sacr\xc3\xa9 bleu! \xe2\x98\x83"
|
||||
dammit = UnicodeDammit(utf8)
|
||||
assert dammit.original_encoding.lower() == 'utf-8'
|
||||
assert dammit.unicode_markup == 'Sacr\xe9 bleu! \N{SNOWMAN}'
|
||||
|
||||
def test_convert_hebrew(self):
|
||||
hebrew = b"\xed\xe5\xec\xf9"
|
||||
dammit = UnicodeDammit(hebrew, ["iso-8859-8"])
|
||||
assert dammit.original_encoding.lower() == 'iso-8859-8'
|
||||
assert dammit.unicode_markup == '\u05dd\u05d5\u05dc\u05e9'
|
||||
|
||||
def test_dont_see_smart_quotes_where_there_are_none(self):
|
||||
utf_8 = b"\343\202\261\343\203\274\343\202\277\343\202\244 Watch"
|
||||
dammit = UnicodeDammit(utf_8)
|
||||
assert dammit.original_encoding.lower() == 'utf-8'
|
||||
assert dammit.unicode_markup.encode("utf-8") == utf_8
|
||||
|
||||
def test_ignore_inappropriate_codecs(self):
|
||||
utf8_data = "Räksmörgås".encode("utf-8")
|
||||
dammit = UnicodeDammit(utf8_data, ["iso-8859-8"])
|
||||
assert dammit.original_encoding.lower() == 'utf-8'
|
||||
|
||||
def test_ignore_invalid_codecs(self):
|
||||
utf8_data = "Räksmörgås".encode("utf-8")
|
||||
for bad_encoding in ['.utf8', '...', 'utF---16.!']:
|
||||
dammit = UnicodeDammit(utf8_data, [bad_encoding])
|
||||
assert dammit.original_encoding.lower() == 'utf-8'
|
||||
|
||||
def test_exclude_encodings(self):
|
||||
# This is UTF-8.
|
||||
utf8_data = "Räksmörgås".encode("utf-8")
|
||||
|
||||
# But if we exclude UTF-8 from consideration, the guess is
|
||||
# Windows-1252.
|
||||
dammit = UnicodeDammit(utf8_data, exclude_encodings=["utf-8"])
|
||||
assert dammit.original_encoding.lower() == 'windows-1252'
|
||||
|
||||
# And if we exclude that, there is no valid guess at all.
|
||||
dammit = UnicodeDammit(
|
||||
utf8_data, exclude_encodings=["utf-8", "windows-1252"])
|
||||
assert dammit.original_encoding == None
|
||||
|
||||
class TestEncodingDetector(object):
|
||||
|
||||
def test_encoding_detector_replaces_junk_in_encoding_name_with_replacement_character(self):
|
||||
detected = EncodingDetector(
|
||||
b'<?xml version="1.0" encoding="UTF-\xdb" ?>')
|
||||
encodings = list(detected.encodings)
|
||||
assert 'utf-\N{REPLACEMENT CHARACTER}' in encodings
|
||||
|
||||
def test_detect_html5_style_meta_tag(self):
|
||||
|
||||
for data in (
|
||||
b'<html><meta charset="euc-jp" /></html>',
|
||||
b"<html><meta charset='euc-jp' /></html>",
|
||||
b"<html><meta charset=euc-jp /></html>",
|
||||
b"<html><meta charset=euc-jp/></html>"):
|
||||
dammit = UnicodeDammit(data, is_html=True)
|
||||
assert "euc-jp" == dammit.original_encoding
|
||||
|
||||
def test_last_ditch_entity_replacement(self):
|
||||
# This is a UTF-8 document that contains bytestrings
|
||||
# completely incompatible with UTF-8 (ie. encoded with some other
|
||||
# encoding).
|
||||
#
|
||||
# Since there is no consistent encoding for the document,
|
||||
# Unicode, Dammit will eventually encode the document as UTF-8
|
||||
# and encode the incompatible characters as REPLACEMENT
|
||||
# CHARACTER.
|
||||
#
|
||||
# If chardet is installed, it will detect that the document
|
||||
# can be converted into ISO-8859-1 without errors. This happens
|
||||
# to be the wrong encoding, but it is a consistent encoding, so the
|
||||
# code we're testing here won't run.
|
||||
#
|
||||
# So we temporarily disable chardet if it's present.
|
||||
doc = b"""\357\273\277<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html><b>\330\250\330\252\330\261</b>
|
||||
<i>\310\322\321\220\312\321\355\344</i></html>"""
|
||||
chardet = bs4.dammit.chardet_dammit
|
||||
logging.disable(logging.WARNING)
|
||||
try:
|
||||
def noop(str):
|
||||
return None
|
||||
bs4.dammit.chardet_dammit = noop
|
||||
dammit = UnicodeDammit(doc)
|
||||
assert True == dammit.contains_replacement_characters
|
||||
assert "\ufffd" in dammit.unicode_markup
|
||||
|
||||
soup = BeautifulSoup(doc, "html.parser")
|
||||
assert soup.contains_replacement_characters
|
||||
finally:
|
||||
logging.disable(logging.NOTSET)
|
||||
bs4.dammit.chardet_dammit = chardet
|
||||
|
||||
def test_byte_order_mark_removed(self):
|
||||
# A document written in UTF-16LE will have its byte order marker stripped.
|
||||
data = b'\xff\xfe<\x00a\x00>\x00\xe1\x00\xe9\x00<\x00/\x00a\x00>\x00'
|
||||
dammit = UnicodeDammit(data)
|
||||
assert "<a>áé</a>" == dammit.unicode_markup
|
||||
assert "utf-16le" == dammit.original_encoding
|
||||
|
||||
def test_known_definite_versus_user_encodings(self):
|
||||
# The known_definite_encodings are used before sniffing the
|
||||
# byte-order mark; the user_encodings are used afterwards.
|
||||
|
||||
# Here's a document in UTF-16LE.
|
||||
data = b'\xff\xfe<\x00a\x00>\x00\xe1\x00\xe9\x00<\x00/\x00a\x00>\x00'
|
||||
dammit = UnicodeDammit(data)
|
||||
|
||||
# We can process it as UTF-16 by passing it in as a known
|
||||
# definite encoding.
|
||||
before = UnicodeDammit(data, known_definite_encodings=["utf-16"])
|
||||
assert "utf-16" == before.original_encoding
|
||||
|
||||
# If we pass UTF-18 as a user encoding, it's not even
|
||||
# tried--the encoding sniffed from the byte-order mark takes
|
||||
# precedence.
|
||||
after = UnicodeDammit(data, user_encodings=["utf-8"])
|
||||
assert "utf-16le" == after.original_encoding
|
||||
assert ["utf-16le"] == [x[0] for x in dammit.tried_encodings]
|
||||
|
||||
# Here's a document in ISO-8859-8.
|
||||
hebrew = b"\xed\xe5\xec\xf9"
|
||||
dammit = UnicodeDammit(hebrew, known_definite_encodings=["utf-8"],
|
||||
user_encodings=["iso-8859-8"])
|
||||
|
||||
# The known_definite_encodings don't work, BOM sniffing does
|
||||
# nothing (it only works for a few UTF encodings), but one of
|
||||
# the user_encodings does work.
|
||||
assert "iso-8859-8" == dammit.original_encoding
|
||||
assert ["utf-8", "iso-8859-8"] == [x[0] for x in dammit.tried_encodings]
|
||||
|
||||
def test_deprecated_override_encodings(self):
|
||||
# override_encodings is a deprecated alias for
|
||||
# known_definite_encodings.
|
||||
hebrew = b"\xed\xe5\xec\xf9"
|
||||
dammit = UnicodeDammit(
|
||||
hebrew,
|
||||
known_definite_encodings=["shift-jis"],
|
||||
override_encodings=["utf-8"],
|
||||
user_encodings=["iso-8859-8"],
|
||||
)
|
||||
assert "iso-8859-8" == dammit.original_encoding
|
||||
|
||||
# known_definite_encodings and override_encodings were tried
|
||||
# before user_encodings.
|
||||
assert ["shift-jis", "utf-8", "iso-8859-8"] == (
|
||||
[x[0] for x in dammit.tried_encodings]
|
||||
)
|
||||
|
||||
def test_detwingle(self):
|
||||
# Here's a UTF8 document.
|
||||
utf8 = ("\N{SNOWMAN}" * 3).encode("utf8")
|
||||
|
||||
# Here's a Windows-1252 document.
|
||||
windows_1252 = (
|
||||
"\N{LEFT DOUBLE QUOTATION MARK}Hi, I like Windows!"
|
||||
"\N{RIGHT DOUBLE QUOTATION MARK}").encode("windows_1252")
|
||||
|
||||
# Through some unholy alchemy, they've been stuck together.
|
||||
doc = utf8 + windows_1252 + utf8
|
||||
|
||||
# The document can't be turned into UTF-8:
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
doc.decode("utf8")
|
||||
|
||||
# Unicode, Dammit thinks the whole document is Windows-1252,
|
||||
# and decodes it into "☃☃☃“Hi, I like Windows!”☃☃☃"
|
||||
|
||||
# But if we run it through fix_embedded_windows_1252, it's fixed:
|
||||
fixed = UnicodeDammit.detwingle(doc)
|
||||
assert "☃☃☃“Hi, I like Windows!”☃☃☃" == fixed.decode("utf8")
|
||||
|
||||
def test_detwingle_ignores_multibyte_characters(self):
|
||||
# Each of these characters has a UTF-8 representation ending
|
||||
# in \x93. \x93 is a smart quote if interpreted as
|
||||
# Windows-1252. But our code knows to skip over multibyte
|
||||
# UTF-8 characters, so they'll survive the process unscathed.
|
||||
for tricky_unicode_char in (
|
||||
"\N{LATIN SMALL LIGATURE OE}", # 2-byte char '\xc5\x93'
|
||||
"\N{LATIN SUBSCRIPT SMALL LETTER X}", # 3-byte char '\xe2\x82\x93'
|
||||
"\xf0\x90\x90\x93", # This is a CJK character, not sure which one.
|
||||
):
|
||||
input = tricky_unicode_char.encode("utf8")
|
||||
assert input.endswith(b'\x93')
|
||||
output = UnicodeDammit.detwingle(input)
|
||||
assert output == input
|
||||
|
||||
def test_find_declared_encoding(self):
|
||||
# Test our ability to find a declared encoding inside an
|
||||
# XML or HTML document.
|
||||
#
|
||||
# Even if the document comes in as Unicode, it may be
|
||||
# interesting to know what encoding was claimed
|
||||
# originally.
|
||||
|
||||
html_unicode = '<html><head><meta charset="utf-8"></head></html>'
|
||||
html_bytes = html_unicode.encode("ascii")
|
||||
|
||||
xml_unicode= '<?xml version="1.0" encoding="ISO-8859-1" ?>'
|
||||
xml_bytes = xml_unicode.encode("ascii")
|
||||
|
||||
m = EncodingDetector.find_declared_encoding
|
||||
assert m(html_unicode, is_html=False) is None
|
||||
assert "utf-8" == m(html_unicode, is_html=True)
|
||||
assert "utf-8" == m(html_bytes, is_html=True)
|
||||
|
||||
assert "iso-8859-1" == m(xml_unicode)
|
||||
assert "iso-8859-1" == m(xml_bytes)
|
||||
|
||||
# Normally, only the first few kilobytes of a document are checked for
|
||||
# an encoding.
|
||||
spacer = b' ' * 5000
|
||||
assert m(spacer + html_bytes) is None
|
||||
assert m(spacer + xml_bytes) is None
|
||||
|
||||
# But you can tell find_declared_encoding to search an entire
|
||||
# HTML document.
|
||||
assert (
|
||||
m(spacer + html_bytes, is_html=True, search_entire_document=True)
|
||||
== "utf-8"
|
||||
)
|
||||
|
||||
# The XML encoding declaration has to be the very first thing
|
||||
# in the document. We'll allow whitespace before the document
|
||||
# starts, but nothing else.
|
||||
assert m(xml_bytes, search_entire_document=True) == "iso-8859-1"
|
||||
assert m(b' ' + xml_bytes, search_entire_document=True) == "iso-8859-1"
|
||||
assert m(b'a' + xml_bytes, search_entire_document=True) is None
|
||||
|
||||
|
||||
class TestEntitySubstitution(object):
|
||||
"""Standalone tests of the EntitySubstitution class."""
|
||||
def setup_method(self):
|
||||
self.sub = EntitySubstitution
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"original,substituted",
|
||||
[
|
||||
# Basic case. Unicode characters corresponding to named
|
||||
# HTML entites are substituted; others are not.
|
||||
("foo\u2200\N{SNOWMAN}\u00f5bar",
|
||||
"foo∀\N{SNOWMAN}õbar"),
|
||||
|
||||
# MS smart quotes are a common source of frustration, so we
|
||||
# give them a special test.
|
||||
('‘’foo“”', "‘’foo“”"),
|
||||
]
|
||||
)
|
||||
def test_substitute_html(self, original, substituted):
|
||||
assert self.sub.substitute_html(original) == substituted
|
||||
|
||||
def test_html5_entity(self):
|
||||
for entity, u in (
|
||||
# A few spot checks of our ability to recognize
|
||||
# special character sequences and convert them
|
||||
# to named entities.
|
||||
('⊧', '\u22a7'),
|
||||
('𝔑', '\U0001d511'),
|
||||
('≧̸', '\u2267\u0338'),
|
||||
('¬', '\xac'),
|
||||
('⫬', '\u2aec'),
|
||||
|
||||
# We _could_ convert | to &verbarr;, but we don't, because
|
||||
# | is an ASCII character.
|
||||
('|' '|'),
|
||||
|
||||
# Similarly for the fj ligature, which we could convert to
|
||||
# fj, but we don't.
|
||||
("fj", "fj"),
|
||||
|
||||
# We do convert _these_ ASCII characters to HTML entities,
|
||||
# because that's required to generate valid HTML.
|
||||
('>', '>'),
|
||||
('<', '<'),
|
||||
('&', '&'),
|
||||
):
|
||||
template = '3 %s 4'
|
||||
raw = template % u
|
||||
with_entities = template % entity
|
||||
assert self.sub.substitute_html(raw) == with_entities
|
||||
|
||||
def test_html5_entity_with_variation_selector(self):
|
||||
# Some HTML5 entities correspond either to a single-character
|
||||
# Unicode sequence _or_ to the same character plus U+FE00,
|
||||
# VARIATION SELECTOR 1. We can handle this.
|
||||
data = "fjords \u2294 penguins"
|
||||
markup = "fjords ⊔ penguins"
|
||||
assert self.sub.substitute_html(data) == markup
|
||||
|
||||
data = "fjords \u2294\ufe00 penguins"
|
||||
markup = "fjords ⊔︀ penguins"
|
||||
assert self.sub.substitute_html(data) == markup
|
||||
|
||||
def test_xml_converstion_includes_no_quotes_if_make_quoted_attribute_is_false(self):
|
||||
s = 'Welcome to "my bar"'
|
||||
assert self.sub.substitute_xml(s, False) == s
|
||||
|
||||
def test_xml_attribute_quoting_normally_uses_double_quotes(self):
|
||||
assert self.sub.substitute_xml("Welcome", True) == '"Welcome"'
|
||||
assert self.sub.substitute_xml("Bob's Bar", True) == '"Bob\'s Bar"'
|
||||
|
||||
def test_xml_attribute_quoting_uses_single_quotes_when_value_contains_double_quotes(self):
|
||||
s = 'Welcome to "my bar"'
|
||||
assert self.sub.substitute_xml(s, True) == "'Welcome to \"my bar\"'"
|
||||
|
||||
def test_xml_attribute_quoting_escapes_single_quotes_when_value_contains_both_single_and_double_quotes(self):
|
||||
s = 'Welcome to "Bob\'s Bar"'
|
||||
assert self.sub.substitute_xml(s, True) == '"Welcome to "Bob\'s Bar""'
|
||||
|
||||
def test_xml_quotes_arent_escaped_when_value_is_not_being_quoted(self):
|
||||
quoted = 'Welcome to "Bob\'s Bar"'
|
||||
assert self.sub.substitute_xml(quoted) == quoted
|
||||
|
||||
def test_xml_quoting_handles_angle_brackets(self):
|
||||
assert self.sub.substitute_xml("foo<bar>") == "foo<bar>"
|
||||
|
||||
def test_xml_quoting_handles_ampersands(self):
|
||||
assert self.sub.substitute_xml("AT&T") == "AT&T"
|
||||
|
||||
def test_xml_quoting_including_ampersands_when_they_are_part_of_an_entity(self):
|
||||
assert self.sub.substitute_xml("ÁT&T") == "&Aacute;T&T"
|
||||
|
||||
def test_xml_quoting_ignoring_ampersands_when_they_are_part_of_an_entity(self):
|
||||
assert self.sub.substitute_xml_containing_entities("ÁT&T") == "ÁT&T"
|
||||
|
||||
def test_quotes_not_html_substituted(self):
|
||||
"""There's no need to do this except inside attribute values."""
|
||||
text = 'Bob\'s "bar"'
|
||||
assert self.sub.substitute_html(text) == text
|
||||
@@ -0,0 +1,38 @@
|
||||
"Test harness for doctests."
|
||||
|
||||
# TODO: Pretty sure this isn't used and should be deleted.
|
||||
|
||||
# pylint: disable-msg=E0611,W0142
|
||||
|
||||
__metaclass__ = type
|
||||
__all__ = [
|
||||
'additional_tests',
|
||||
]
|
||||
|
||||
import atexit
|
||||
import doctest
|
||||
import os
|
||||
#from pkg_resources import (
|
||||
# resource_filename, resource_exists, resource_listdir, cleanup_resources)
|
||||
import unittest
|
||||
|
||||
DOCTEST_FLAGS = (
|
||||
doctest.ELLIPSIS |
|
||||
doctest.NORMALIZE_WHITESPACE |
|
||||
doctest.REPORT_NDIFF)
|
||||
|
||||
|
||||
# def additional_tests():
|
||||
# "Run the doc tests (README.txt and docs/*, if any exist)"
|
||||
# doctest_files = [
|
||||
# os.path.abspath(resource_filename('bs4', 'README.txt'))]
|
||||
# if resource_exists('bs4', 'docs'):
|
||||
# for name in resource_listdir('bs4', 'docs'):
|
||||
# if name.endswith('.txt'):
|
||||
# doctest_files.append(
|
||||
# os.path.abspath(
|
||||
# resource_filename('bs4', 'docs/%s' % name)))
|
||||
# kwargs = dict(module_relative=False, optionflags=DOCTEST_FLAGS)
|
||||
# atexit.register(cleanup_resources)
|
||||
# return unittest.TestSuite((
|
||||
# doctest.DocFileSuite(*doctest_files, **kwargs)))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests of classes in element.py.
|
||||
|
||||
The really big classes -- Tag, PageElement, and NavigableString --
|
||||
are tested in separate files.
|
||||
"""
|
||||
|
||||
from bs4.element import (
|
||||
CharsetMetaAttributeValue,
|
||||
ContentMetaAttributeValue,
|
||||
NamespacedAttribute,
|
||||
)
|
||||
from . import SoupTest
|
||||
|
||||
|
||||
class TestNamedspacedAttribute(object):
|
||||
|
||||
def test_name_may_be_none_or_missing(self):
|
||||
a = NamespacedAttribute("xmlns", None)
|
||||
assert a == "xmlns"
|
||||
|
||||
a = NamespacedAttribute("xmlns", "")
|
||||
assert a == "xmlns"
|
||||
|
||||
a = NamespacedAttribute("xmlns")
|
||||
assert a == "xmlns"
|
||||
|
||||
def test_namespace_may_be_none_or_missing(self):
|
||||
a = NamespacedAttribute(None, "tag")
|
||||
assert a == "tag"
|
||||
|
||||
a = NamespacedAttribute("", "tag")
|
||||
assert a == "tag"
|
||||
|
||||
def test_attribute_is_equivalent_to_colon_separated_string(self):
|
||||
a = NamespacedAttribute("a", "b")
|
||||
assert "a:b" == a
|
||||
|
||||
def test_attributes_are_equivalent_if_prefix_and_name_identical(self):
|
||||
a = NamespacedAttribute("a", "b", "c")
|
||||
b = NamespacedAttribute("a", "b", "c")
|
||||
assert a == b
|
||||
|
||||
# The actual namespace is not considered.
|
||||
c = NamespacedAttribute("a", "b", None)
|
||||
assert a == c
|
||||
|
||||
# But name and prefix are important.
|
||||
d = NamespacedAttribute("a", "z", "c")
|
||||
assert a != d
|
||||
|
||||
e = NamespacedAttribute("z", "b", "c")
|
||||
assert a != e
|
||||
|
||||
|
||||
class TestAttributeValueWithCharsetSubstitution(object):
|
||||
"""Certain attributes are designed to have the charset of the
|
||||
final document substituted into their value.
|
||||
"""
|
||||
|
||||
def test_content_meta_attribute_value(self):
|
||||
# The value of a CharsetMetaAttributeValue is whatever
|
||||
# encoding the string is in.
|
||||
value = CharsetMetaAttributeValue("euc-jp")
|
||||
assert "euc-jp" == value
|
||||
assert "euc-jp" == value.original_value
|
||||
assert "utf8" == value.encode("utf8")
|
||||
assert "ascii" == value.encode("ascii")
|
||||
|
||||
def test_content_meta_attribute_value(self):
|
||||
value = ContentMetaAttributeValue("text/html; charset=euc-jp")
|
||||
assert "text/html; charset=euc-jp" == value
|
||||
assert "text/html; charset=euc-jp" == value.original_value
|
||||
assert "text/html; charset=utf8" == value.encode("utf8")
|
||||
assert "text/html; charset=ascii" == value.encode("ascii")
|
||||
@@ -0,0 +1,113 @@
|
||||
import pytest
|
||||
|
||||
from bs4.element import Tag
|
||||
from bs4.formatter import (
|
||||
Formatter,
|
||||
HTMLFormatter,
|
||||
XMLFormatter,
|
||||
)
|
||||
from . import SoupTest
|
||||
|
||||
class TestFormatter(SoupTest):
|
||||
|
||||
def test_default_attributes(self):
|
||||
# Test the default behavior of Formatter.attributes().
|
||||
formatter = Formatter()
|
||||
tag = Tag(name="tag")
|
||||
tag['b'] = 1
|
||||
tag['a'] = 2
|
||||
|
||||
# Attributes come out sorted by name. In Python 3, attributes
|
||||
# normally come out of a dictionary in the order they were
|
||||
# added.
|
||||
assert [('a', 2), ('b', 1)] == formatter.attributes(tag)
|
||||
|
||||
# This works even if Tag.attrs is None, though this shouldn't
|
||||
# normally happen.
|
||||
tag.attrs = None
|
||||
assert [] == formatter.attributes(tag)
|
||||
|
||||
assert ' ' == formatter.indent
|
||||
|
||||
def test_sort_attributes(self):
|
||||
# Test the ability to override Formatter.attributes() to,
|
||||
# e.g., disable the normal sorting of attributes.
|
||||
class UnsortedFormatter(Formatter):
|
||||
def attributes(self, tag):
|
||||
self.called_with = tag
|
||||
for k, v in sorted(tag.attrs.items()):
|
||||
if k == 'ignore':
|
||||
continue
|
||||
yield k,v
|
||||
|
||||
soup = self.soup('<p cval="1" aval="2" ignore="ignored"></p>')
|
||||
formatter = UnsortedFormatter()
|
||||
decoded = soup.decode(formatter=formatter)
|
||||
|
||||
# attributes() was called on the <p> tag. It filtered out one
|
||||
# attribute and sorted the other two.
|
||||
assert formatter.called_with == soup.p
|
||||
assert '<p aval="2" cval="1"></p>' == decoded
|
||||
|
||||
def test_empty_attributes_are_booleans(self):
|
||||
# Test the behavior of empty_attributes_are_booleans as well
|
||||
# as which Formatters have it enabled.
|
||||
|
||||
for name in ('html', 'minimal', None):
|
||||
formatter = HTMLFormatter.REGISTRY[name]
|
||||
assert False == formatter.empty_attributes_are_booleans
|
||||
|
||||
formatter = XMLFormatter.REGISTRY[None]
|
||||
assert False == formatter.empty_attributes_are_booleans
|
||||
|
||||
formatter = HTMLFormatter.REGISTRY['html5']
|
||||
assert True == formatter.empty_attributes_are_booleans
|
||||
|
||||
# Verify that the constructor sets the value.
|
||||
formatter = Formatter(empty_attributes_are_booleans=True)
|
||||
assert True == formatter.empty_attributes_are_booleans
|
||||
|
||||
# Now demonstrate what it does to markup.
|
||||
for markup in (
|
||||
"<option selected></option>",
|
||||
'<option selected=""></option>'
|
||||
):
|
||||
soup = self.soup(markup)
|
||||
for formatter in ('html', 'minimal', 'xml', None):
|
||||
assert b'<option selected=""></option>' == soup.option.encode(formatter='html')
|
||||
assert b'<option selected></option>' == soup.option.encode(formatter='html5')
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indent,expect",
|
||||
[
|
||||
(None, '<a>\n<b>\ntext\n</b>\n</a>\n'),
|
||||
(-1, '<a>\n<b>\ntext\n</b>\n</a>\n'),
|
||||
(0, '<a>\n<b>\ntext\n</b>\n</a>\n'),
|
||||
("", '<a>\n<b>\ntext\n</b>\n</a>\n'),
|
||||
|
||||
(1, '<a>\n <b>\n text\n </b>\n</a>\n'),
|
||||
(2, '<a>\n <b>\n text\n </b>\n</a>\n'),
|
||||
|
||||
("\t", '<a>\n\t<b>\n\t\ttext\n\t</b>\n</a>\n'),
|
||||
('abc', '<a>\nabc<b>\nabcabctext\nabc</b>\n</a>\n'),
|
||||
|
||||
# Some invalid inputs -- the default behavior is used.
|
||||
(object(), '<a>\n <b>\n text\n </b>\n</a>\n'),
|
||||
(b'bytes', '<a>\n <b>\n text\n </b>\n</a>\n'),
|
||||
]
|
||||
)
|
||||
def test_indent(self, indent, expect):
|
||||
# Pretty-print a tree with a Formatter set to
|
||||
# indent in a certain way and verify the results.
|
||||
soup = self.soup("<a><b>text</b></a>")
|
||||
formatter = Formatter(indent=indent)
|
||||
assert soup.prettify(formatter=formatter) == expect
|
||||
|
||||
# Pretty-printing only happens with prettify(), not
|
||||
# encode().
|
||||
assert soup.encode(formatter=formatter) != expect
|
||||
|
||||
def test_default_indent_value(self):
|
||||
formatter = Formatter()
|
||||
assert formatter.indent == ' '
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""This file contains test cases reported by third parties using
|
||||
fuzzing tools, primarily from Google's oss-fuzz project. Some of these
|
||||
represent real problems with Beautiful Soup, but many are problems in
|
||||
libraries that Beautiful Soup depends on, and many of the test cases
|
||||
represent different ways of triggering the same problem.
|
||||
|
||||
Grouping these test cases together makes it easy to see which test
|
||||
cases represent the same problem, and puts the test cases in close
|
||||
proximity to code that can trigger the problems.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
from bs4 import (
|
||||
BeautifulSoup,
|
||||
ParserRejectedMarkup,
|
||||
)
|
||||
try:
|
||||
from soupsieve.util import SelectorSyntaxError
|
||||
import lxml
|
||||
import html5lib
|
||||
fully_fuzzable = True
|
||||
except ImportError:
|
||||
fully_fuzzable = False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not fully_fuzzable, reason="Prerequisites for fuzz tests are not installed.")
|
||||
class TestFuzz(object):
|
||||
|
||||
# Test case markup files from fuzzers are given this extension so
|
||||
# they can be included in builds.
|
||||
TESTCASE_SUFFIX = ".testcase"
|
||||
|
||||
# Copied 20230512 from
|
||||
# https://github.com/google/oss-fuzz/blob/4ac6a645a197a695fe76532251feb5067076b3f3/projects/bs4/bs4_fuzzer.py
|
||||
#
|
||||
# Copying the code lets us precisely duplicate the behavior of
|
||||
# oss-fuzz. The downside is that this code changes over time, so
|
||||
# multiple copies of the code must be kept around to run against
|
||||
# older tests. I'm not sure what to do about this, but I may
|
||||
# retire old tests after a time.
|
||||
def fuzz_test_with_css(self, filename):
|
||||
data = self.__markup(filename)
|
||||
parsers = ['lxml-xml', 'html5lib', 'html.parser', 'lxml']
|
||||
try:
|
||||
idx = int(data[0]) % len(parsers)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
css_selector, data = data[1:10], data[10:]
|
||||
|
||||
try:
|
||||
soup = BeautifulSoup(data[1:], features=parsers[idx])
|
||||
except ParserRejectedMarkup:
|
||||
return
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
list(soup.find_all(True))
|
||||
try:
|
||||
soup.css.select(css_selector.decode('utf-8', 'replace'))
|
||||
except SelectorSyntaxError:
|
||||
return
|
||||
soup.prettify()
|
||||
|
||||
# This class of error has been fixed by catching a less helpful
|
||||
# exception from html.parser and raising ParserRejectedMarkup
|
||||
# instead.
|
||||
@pytest.mark.parametrize(
|
||||
"filename", [
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5703933063462912",
|
||||
"crash-ffbdfa8a2b26f13537b68d3794b0478a4090ee4a",
|
||||
]
|
||||
)
|
||||
def test_rejected_markup(self, filename):
|
||||
markup = self.__markup(filename)
|
||||
with pytest.raises(ParserRejectedMarkup):
|
||||
BeautifulSoup(markup, 'html.parser')
|
||||
|
||||
# This class of error has to do with very deeply nested documents
|
||||
# which overflow the Python call stack when the tree is converted
|
||||
# to a string. This is an issue with Beautiful Soup which was fixed
|
||||
# as part of [bug=1471755].
|
||||
#
|
||||
# These test cases are in the older format that doesn't specify
|
||||
# which parser to use or give a CSS selector.
|
||||
@pytest.mark.parametrize(
|
||||
"filename", [
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5984173902397440",
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5167584867909632",
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-6124268085182464",
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-6450958476902400",
|
||||
]
|
||||
)
|
||||
def test_deeply_nested_document_without_css(self, filename):
|
||||
# Parsing the document and encoding it back to a string is
|
||||
# sufficient to demonstrate that the overflow problem has
|
||||
# been fixed.
|
||||
markup = self.__markup(filename)
|
||||
BeautifulSoup(markup, 'html.parser').encode()
|
||||
|
||||
# This class of error has to do with very deeply nested documents
|
||||
# which overflow the Python call stack when the tree is converted
|
||||
# to a string. This is an issue with Beautiful Soup which was fixed
|
||||
# as part of [bug=1471755].
|
||||
@pytest.mark.parametrize(
|
||||
"filename", [
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5000587759190016",
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5375146639360000",
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5492400320282624",
|
||||
]
|
||||
)
|
||||
def test_deeply_nested_document(self, filename):
|
||||
self.fuzz_test_with_css(filename)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename", [
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-4670634698080256",
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5270998950477824",
|
||||
]
|
||||
)
|
||||
def test_soupsieve_errors(self, filename):
|
||||
self.fuzz_test_with_css(filename)
|
||||
|
||||
# This class of error represents problems with html5lib's parser,
|
||||
# not Beautiful Soup. I use
|
||||
# https://github.com/html5lib/html5lib-python/issues/568 to notify
|
||||
# the html5lib developers of these issues.
|
||||
#
|
||||
# These test cases are in the older format that doesn't specify
|
||||
# which parser to use or give a CSS selector.
|
||||
@pytest.mark.skip(reason="html5lib-specific problems")
|
||||
@pytest.mark.parametrize(
|
||||
"filename", [
|
||||
# b"""ÿ<!DOCTyPEV PUBLIC'''Ð'"""
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-4818336571064320",
|
||||
|
||||
# b')<a><math><TR><a><mI><a><p><a>'
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-4999465949331456",
|
||||
|
||||
# b'-<math><sElect><mi><sElect><sElect>'
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-5843991618256896",
|
||||
|
||||
# b'ñ<table><svg><html>'
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-6241471367348224",
|
||||
|
||||
# <TABLE>, some ^@ characters, some <math> tags.
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-6600557255327744",
|
||||
|
||||
# Nested table
|
||||
"crash-0d306a50c8ed8bcd0785b67000fcd5dea1d33f08"
|
||||
]
|
||||
)
|
||||
def test_html5lib_parse_errors_without_css(self, filename):
|
||||
markup = self.__markup(filename)
|
||||
print(BeautifulSoup(markup, 'html5lib').encode())
|
||||
|
||||
# This class of error represents problems with html5lib's parser,
|
||||
# not Beautiful Soup. I use
|
||||
# https://github.com/html5lib/html5lib-python/issues/568 to notify
|
||||
# the html5lib developers of these issues.
|
||||
@pytest.mark.skip(reason="html5lib-specific problems")
|
||||
@pytest.mark.parametrize(
|
||||
"filename", [
|
||||
# b'- \xff\xff <math>\x10<select><mi><select><select>t'
|
||||
"clusterfuzz-testcase-minimized-bs4_fuzzer-6306874195312640",
|
||||
]
|
||||
)
|
||||
def test_html5lib_parse_errors(self, filename):
|
||||
self.fuzz_test_with_css(filename)
|
||||
|
||||
def __markup(self, filename):
|
||||
if not filename.endswith(self.TESTCASE_SUFFIX):
|
||||
filename += self.TESTCASE_SUFFIX
|
||||
this_dir = os.path.split(__file__)[0]
|
||||
path = os.path.join(this_dir, 'fuzz', filename)
|
||||
return open(path, 'rb').read()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests to ensure that the html5lib tree builder generates good trees."""
|
||||
|
||||
import pytest
|
||||
import warnings
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import SoupStrainer
|
||||
from . import (
|
||||
HTML5LIB_PRESENT,
|
||||
HTML5TreeBuilderSmokeTest,
|
||||
SoupTest,
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not HTML5LIB_PRESENT,
|
||||
reason="html5lib seems not to be present, not testing its tree builder."
|
||||
)
|
||||
class TestHTML5LibBuilder(SoupTest, HTML5TreeBuilderSmokeTest):
|
||||
"""See ``HTML5TreeBuilderSmokeTest``."""
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
from bs4.builder import HTML5TreeBuilder
|
||||
return HTML5TreeBuilder
|
||||
|
||||
def test_soupstrainer(self):
|
||||
# The html5lib tree builder does not support SoupStrainers.
|
||||
strainer = SoupStrainer("b")
|
||||
markup = "<p>A <b>bold</b> statement.</p>"
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulSoup(markup, "html5lib", parse_only=strainer)
|
||||
assert soup.decode() == self.document_for(markup)
|
||||
|
||||
[warning] = w
|
||||
assert warning.filename == __file__
|
||||
assert "the html5lib tree builder doesn't support parse_only" in str(warning.message)
|
||||
|
||||
def test_correctly_nested_tables(self):
|
||||
"""html5lib inserts <tbody> tags where other parsers don't."""
|
||||
markup = ('<table id="1">'
|
||||
'<tr>'
|
||||
"<td>Here's another table:"
|
||||
'<table id="2">'
|
||||
'<tr><td>foo</td></tr>'
|
||||
'</table></td>')
|
||||
|
||||
self.assert_soup(
|
||||
markup,
|
||||
'<table id="1"><tbody><tr><td>Here\'s another table:'
|
||||
'<table id="2"><tbody><tr><td>foo</td></tr></tbody></table>'
|
||||
'</td></tr></tbody></table>')
|
||||
|
||||
self.assert_soup(
|
||||
"<table><thead><tr><td>Foo</td></tr></thead>"
|
||||
"<tbody><tr><td>Bar</td></tr></tbody>"
|
||||
"<tfoot><tr><td>Baz</td></tr></tfoot></table>")
|
||||
|
||||
def test_xml_declaration_followed_by_doctype(self):
|
||||
markup = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<p>foo</p>
|
||||
</body>
|
||||
</html>'''
|
||||
soup = self.soup(markup)
|
||||
# Verify that we can reach the <p> tag; this means the tree is connected.
|
||||
assert b"<p>foo</p>" == soup.p.encode()
|
||||
|
||||
def test_reparented_markup(self):
|
||||
markup = '<p><em>foo</p>\n<p>bar<a></a></em></p>'
|
||||
soup = self.soup(markup)
|
||||
assert "<body><p><em>foo</em></p><em>\n</em><p><em>bar<a></a></em></p></body>" == soup.body.decode()
|
||||
assert 2 == len(soup.find_all('p'))
|
||||
|
||||
|
||||
def test_reparented_markup_ends_with_whitespace(self):
|
||||
markup = '<p><em>foo</p>\n<p>bar<a></a></em></p>\n'
|
||||
soup = self.soup(markup)
|
||||
assert "<body><p><em>foo</em></p><em>\n</em><p><em>bar<a></a></em></p>\n</body>" == soup.body.decode()
|
||||
assert 2 == len(soup.find_all('p'))
|
||||
|
||||
def test_reparented_markup_containing_identical_whitespace_nodes(self):
|
||||
"""Verify that we keep the two whitespace nodes in this
|
||||
document distinct when reparenting the adjacent <tbody> tags.
|
||||
"""
|
||||
markup = '<table> <tbody><tbody><ims></tbody> </table>'
|
||||
soup = self.soup(markup)
|
||||
space1, space2 = soup.find_all(string=' ')
|
||||
tbody1, tbody2 = soup.find_all('tbody')
|
||||
assert space1.next_element is tbody1
|
||||
assert tbody2.next_element is space2
|
||||
|
||||
def test_reparented_markup_containing_children(self):
|
||||
markup = '<div><a>aftermath<p><noscript>target</noscript>aftermath</a></p></div>'
|
||||
soup = self.soup(markup)
|
||||
noscript = soup.noscript
|
||||
assert "target" == noscript.next_element
|
||||
target = soup.find(string='target')
|
||||
|
||||
# The 'aftermath' string was duplicated; we want the second one.
|
||||
final_aftermath = soup.find_all(string='aftermath')[-1]
|
||||
|
||||
# The <noscript> tag was moved beneath a copy of the <a> tag,
|
||||
# but the 'target' string within is still connected to the
|
||||
# (second) 'aftermath' string.
|
||||
assert final_aftermath == target.next_element
|
||||
assert target == final_aftermath.previous_element
|
||||
|
||||
def test_processing_instruction(self):
|
||||
"""Processing instructions become comments."""
|
||||
markup = b"""<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
assert str(soup).startswith("<!--?PITarget PIContent?-->")
|
||||
|
||||
def test_cloned_multivalue_node(self):
|
||||
markup = b"""<a class="my_class"><p></a>"""
|
||||
soup = self.soup(markup)
|
||||
a1, a2 = soup.find_all('a')
|
||||
assert a1 == a2
|
||||
assert a1 is not a2
|
||||
|
||||
def test_foster_parenting(self):
|
||||
markup = b"""<table><td></tbody>A"""
|
||||
soup = self.soup(markup)
|
||||
assert "<body>A<table><tbody><tr><td></td></tr></tbody></table></body>" == soup.body.decode()
|
||||
|
||||
def test_extraction(self):
|
||||
"""
|
||||
Test that extraction does not destroy the tree.
|
||||
|
||||
https://bugs.launchpad.net/beautifulsoup/+bug/1782928
|
||||
"""
|
||||
|
||||
markup = """
|
||||
<html><head></head>
|
||||
<style>
|
||||
</style><script></script><body><p>hello</p></body></html>
|
||||
"""
|
||||
soup = self.soup(markup)
|
||||
[s.extract() for s in soup('script')]
|
||||
[s.extract() for s in soup('style')]
|
||||
|
||||
assert len(soup.find_all("p")) == 1
|
||||
|
||||
def test_empty_comment(self):
|
||||
"""
|
||||
Test that empty comment does not break structure.
|
||||
|
||||
https://bugs.launchpad.net/beautifulsoup/+bug/1806598
|
||||
"""
|
||||
|
||||
markup = """
|
||||
<html>
|
||||
<body>
|
||||
<form>
|
||||
<!----><input type="text">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
soup = self.soup(markup)
|
||||
inputs = []
|
||||
for form in soup.find_all('form'):
|
||||
inputs.extend(form.find_all('input'))
|
||||
assert len(inputs) == 1
|
||||
|
||||
def test_tracking_line_numbers(self):
|
||||
# The html.parser TreeBuilder keeps track of line number and
|
||||
# position of each element.
|
||||
markup = "\n <p>\n\n<sourceline>\n<b>text</b></sourceline><sourcepos></p>"
|
||||
soup = self.soup(markup)
|
||||
assert 2 == soup.p.sourceline
|
||||
assert 5 == soup.p.sourcepos
|
||||
assert "sourceline" == soup.p.find('sourceline').name
|
||||
|
||||
# You can deactivate this behavior.
|
||||
soup = self.soup(markup, store_line_numbers=False)
|
||||
assert "sourceline" == soup.p.sourceline.name
|
||||
assert "sourcepos" == soup.p.sourcepos.name
|
||||
|
||||
def test_special_string_containers(self):
|
||||
# The html5lib tree builder doesn't support this standard feature,
|
||||
# because there's no way of knowing, when a string is created,
|
||||
# where in the tree it will eventually end up.
|
||||
pass
|
||||
|
||||
def test_html5_attributes(self):
|
||||
# The html5lib TreeBuilder can convert any entity named in
|
||||
# the HTML5 spec to a sequence of Unicode characters, and
|
||||
# convert those Unicode characters to a (potentially
|
||||
# different) named entity on the way out.
|
||||
#
|
||||
# This is a copy of the same test from
|
||||
# HTMLParserTreeBuilderSmokeTest. It's not in the superclass
|
||||
# because the lxml HTML TreeBuilder _doesn't_ work this way.
|
||||
for input_element, output_unicode, output_element in (
|
||||
("⇄", '\u21c4', b'⇄'),
|
||||
('⊧', '\u22a7', b'⊧'),
|
||||
('𝔑', '\U0001d511', b'𝔑'),
|
||||
('≧̸', '\u2267\u0338', b'≧̸'),
|
||||
('¬', '\xac', b'¬'),
|
||||
('⫬', '\u2aec', b'⫬'),
|
||||
('"', '"', b'"'),
|
||||
('∴', '\u2234', b'∴'),
|
||||
('∴', '\u2234', b'∴'),
|
||||
('∴', '\u2234', b'∴'),
|
||||
("fj", 'fj', b'fj'),
|
||||
("⊔", '\u2294', b'⊔'),
|
||||
("⊔︀", '\u2294\ufe00', b'⊔︀'),
|
||||
("'", "'", b"'"),
|
||||
("|", "|", b"|"),
|
||||
):
|
||||
markup = '<div>%s</div>' % input_element
|
||||
div = self.soup(markup).div
|
||||
without_element = div.encode()
|
||||
expect = b"<div>%s</div>" % output_unicode.encode("utf8")
|
||||
assert without_element == expect
|
||||
|
||||
with_element = div.encode(formatter="html")
|
||||
expect = b"<div>%s</div>" % output_element
|
||||
assert with_element == expect
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests to ensure that the html.parser tree builder generates good
|
||||
trees."""
|
||||
|
||||
from pdb import set_trace
|
||||
import pickle
|
||||
import pytest
|
||||
import warnings
|
||||
from bs4.builder import (
|
||||
HTMLParserTreeBuilder,
|
||||
ParserRejectedMarkup,
|
||||
XMLParsedAsHTMLWarning,
|
||||
)
|
||||
from bs4.builder._htmlparser import BeautifulSoupHTMLParser
|
||||
from . import SoupTest, HTMLTreeBuilderSmokeTest
|
||||
|
||||
class TestHTMLParserTreeBuilder(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
|
||||
default_builder = HTMLParserTreeBuilder
|
||||
|
||||
def test_rejected_input(self):
|
||||
# Python's html.parser will occasionally reject markup,
|
||||
# especially when there is a problem with the initial DOCTYPE
|
||||
# declaration. Different versions of Python sound the alarm in
|
||||
# different ways, but Beautiful Soup consistently raises
|
||||
# errors as ParserRejectedMarkup exceptions.
|
||||
bad_markup = [
|
||||
# https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=28873
|
||||
# https://github.com/guidovranken/python-library-fuzzers/blob/master/corp-html/519e5b4269a01185a0d5e76295251921da2f0700
|
||||
# https://github.com/python/cpython/issues/81928
|
||||
b'\n<![\xff\xfe\xfe\xcd\x00',
|
||||
|
||||
#https://github.com/guidovranken/python-library-fuzzers/blob/master/corp-html/de32aa55785be29bbc72a1a8e06b00611fb3d9f8
|
||||
# https://github.com/python/cpython/issues/78661
|
||||
#
|
||||
b'<![n\x00',
|
||||
b"<![UNKNOWN[]]>",
|
||||
]
|
||||
for markup in bad_markup:
|
||||
with pytest.raises(ParserRejectedMarkup):
|
||||
soup = self.soup(markup)
|
||||
|
||||
def test_namespaced_system_doctype(self):
|
||||
# html.parser can't handle namespaced doctypes, so skip this one.
|
||||
pass
|
||||
|
||||
def test_namespaced_public_doctype(self):
|
||||
# html.parser can't handle namespaced doctypes, so skip this one.
|
||||
pass
|
||||
|
||||
def test_builder_is_pickled(self):
|
||||
"""Unlike most tree builders, HTMLParserTreeBuilder and will
|
||||
be restored after pickling.
|
||||
"""
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
assert isinstance(loaded.builder, type(tree.builder))
|
||||
|
||||
def test_redundant_empty_element_closing_tags(self):
|
||||
self.assert_soup('<br></br><br></br><br></br>', "<br/><br/><br/>")
|
||||
self.assert_soup('</br></br></br>', "")
|
||||
|
||||
def test_empty_element(self):
|
||||
# This verifies that any buffered data present when the parser
|
||||
# finishes working is handled.
|
||||
self.assert_soup("foo &# bar", "foo &# bar")
|
||||
|
||||
def test_tracking_line_numbers(self):
|
||||
# The html.parser TreeBuilder keeps track of line number and
|
||||
# position of each element.
|
||||
markup = "\n <p>\n\n<sourceline>\n<b>text</b></sourceline><sourcepos></p>"
|
||||
soup = self.soup(markup)
|
||||
assert 2 == soup.p.sourceline
|
||||
assert 3 == soup.p.sourcepos
|
||||
assert "sourceline" == soup.p.find('sourceline').name
|
||||
|
||||
# You can deactivate this behavior.
|
||||
soup = self.soup(markup, store_line_numbers=False)
|
||||
assert "sourceline" == soup.p.sourceline.name
|
||||
assert "sourcepos" == soup.p.sourcepos.name
|
||||
|
||||
def test_on_duplicate_attribute(self):
|
||||
# The html.parser tree builder has a variety of ways of
|
||||
# handling a tag that contains the same attribute multiple times.
|
||||
|
||||
markup = '<a class="cls" href="url1" href="url2" href="url3" id="id">'
|
||||
|
||||
# If you don't provide any particular value for
|
||||
# on_duplicate_attribute, later values replace earlier values.
|
||||
soup = self.soup(markup)
|
||||
assert "url3" == soup.a['href']
|
||||
assert ["cls"] == soup.a['class']
|
||||
assert "id" == soup.a['id']
|
||||
|
||||
# You can also get this behavior explicitly.
|
||||
def assert_attribute(on_duplicate_attribute, expected):
|
||||
soup = self.soup(
|
||||
markup, on_duplicate_attribute=on_duplicate_attribute
|
||||
)
|
||||
assert expected == soup.a['href']
|
||||
|
||||
# Verify that non-duplicate attributes are treated normally.
|
||||
assert ["cls"] == soup.a['class']
|
||||
assert "id" == soup.a['id']
|
||||
assert_attribute(None, "url3")
|
||||
assert_attribute(BeautifulSoupHTMLParser.REPLACE, "url3")
|
||||
|
||||
# You can ignore subsequent values in favor of the first.
|
||||
assert_attribute(BeautifulSoupHTMLParser.IGNORE, "url1")
|
||||
|
||||
# And you can pass in a callable that does whatever you want.
|
||||
def accumulate(attrs, key, value):
|
||||
if not isinstance(attrs[key], list):
|
||||
attrs[key] = [attrs[key]]
|
||||
attrs[key].append(value)
|
||||
assert_attribute(accumulate, ["url1", "url2", "url3"])
|
||||
|
||||
def test_html5_attributes(self):
|
||||
# The html.parser TreeBuilder can convert any entity named in
|
||||
# the HTML5 spec to a sequence of Unicode characters, and
|
||||
# convert those Unicode characters to a (potentially
|
||||
# different) named entity on the way out.
|
||||
for input_element, output_unicode, output_element in (
|
||||
("⇄", '\u21c4', b'⇄'),
|
||||
('⊧', '\u22a7', b'⊧'),
|
||||
('𝔑', '\U0001d511', b'𝔑'),
|
||||
('≧̸', '\u2267\u0338', b'≧̸'),
|
||||
('¬', '\xac', b'¬'),
|
||||
('⫬', '\u2aec', b'⫬'),
|
||||
('"', '"', b'"'),
|
||||
('∴', '\u2234', b'∴'),
|
||||
('∴', '\u2234', b'∴'),
|
||||
('∴', '\u2234', b'∴'),
|
||||
("fj", 'fj', b'fj'),
|
||||
("⊔", '\u2294', b'⊔'),
|
||||
("⊔︀", '\u2294\ufe00', b'⊔︀'),
|
||||
("'", "'", b"'"),
|
||||
("|", "|", b"|"),
|
||||
):
|
||||
markup = '<div>%s</div>' % input_element
|
||||
div = self.soup(markup).div
|
||||
without_element = div.encode()
|
||||
expect = b"<div>%s</div>" % output_unicode.encode("utf8")
|
||||
assert without_element == expect
|
||||
|
||||
with_element = div.encode(formatter="html")
|
||||
expect = b"<div>%s</div>" % output_element
|
||||
assert with_element == expect
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Tests to ensure that the lxml tree builder generates good trees."""
|
||||
|
||||
import pickle
|
||||
import pytest
|
||||
import re
|
||||
import warnings
|
||||
from . import LXML_PRESENT, LXML_VERSION
|
||||
|
||||
if LXML_PRESENT:
|
||||
from bs4.builder import LXMLTreeBuilder, LXMLTreeBuilderForXML
|
||||
|
||||
from bs4 import (
|
||||
BeautifulSoup,
|
||||
BeautifulStoneSoup,
|
||||
)
|
||||
from bs4.element import Comment, Doctype, SoupStrainer
|
||||
from . import (
|
||||
HTMLTreeBuilderSmokeTest,
|
||||
XMLTreeBuilderSmokeTest,
|
||||
SOUP_SIEVE_PRESENT,
|
||||
SoupTest,
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not LXML_PRESENT,
|
||||
reason="lxml seems not to be present, not testing its tree builder."
|
||||
)
|
||||
class TestLXMLTreeBuilder(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
"""See ``HTMLTreeBuilderSmokeTest``."""
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return LXMLTreeBuilder
|
||||
|
||||
def test_out_of_range_entity(self):
|
||||
self.assert_soup(
|
||||
"<p>foo�bar</p>", "<p>foobar</p>")
|
||||
self.assert_soup(
|
||||
"<p>foo�bar</p>", "<p>foobar</p>")
|
||||
self.assert_soup(
|
||||
"<p>foo�bar</p>", "<p>foobar</p>")
|
||||
|
||||
def test_entities_in_foreign_document_encoding(self):
|
||||
# We can't implement this case correctly because by the time we
|
||||
# hear about markup like "“", it's been (incorrectly) converted into
|
||||
# a string like u'\x93'
|
||||
pass
|
||||
|
||||
# In lxml < 2.3.5, an empty doctype causes a segfault. Skip this
|
||||
# test if an old version of lxml is installed.
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not LXML_PRESENT or LXML_VERSION < (2,3,5,0),
|
||||
reason="Skipping doctype test for old version of lxml to avoid segfault."
|
||||
)
|
||||
def test_empty_doctype(self):
|
||||
soup = self.soup("<!DOCTYPE>")
|
||||
doctype = soup.contents[0]
|
||||
assert "" == doctype.strip()
|
||||
|
||||
def test_beautifulstonesoup_is_xml_parser(self):
|
||||
# Make sure that the deprecated BSS class uses an xml builder
|
||||
# if one is installed.
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulStoneSoup("<b />")
|
||||
assert "<b/>" == str(soup.b)
|
||||
[warning] = w
|
||||
assert warning.filename == __file__
|
||||
assert "BeautifulStoneSoup class is deprecated" in str(warning.message)
|
||||
|
||||
def test_tracking_line_numbers(self):
|
||||
# The lxml TreeBuilder cannot keep track of line numbers from
|
||||
# the original markup. Even if you ask for line numbers, we
|
||||
# don't have 'em.
|
||||
#
|
||||
# This means that if you have a tag like <sourceline> or
|
||||
# <sourcepos>, attribute access will find it rather than
|
||||
# giving you a numeric answer.
|
||||
soup = self.soup(
|
||||
"\n <p>\n\n<sourceline>\n<b>text</b></sourceline><sourcepos></p>",
|
||||
store_line_numbers=True
|
||||
)
|
||||
assert "sourceline" == soup.p.sourceline.name
|
||||
assert "sourcepos" == soup.p.sourcepos.name
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not LXML_PRESENT,
|
||||
reason="lxml seems not to be present, not testing its XML tree builder."
|
||||
)
|
||||
class TestLXMLXMLTreeBuilder(SoupTest, XMLTreeBuilderSmokeTest):
|
||||
"""See ``HTMLTreeBuilderSmokeTest``."""
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return LXMLTreeBuilderForXML
|
||||
|
||||
def test_namespace_indexing(self):
|
||||
soup = self.soup(
|
||||
'<?xml version="1.1"?>\n'
|
||||
'<root>'
|
||||
'<tag xmlns="http://unprefixed-namespace.com">content</tag>'
|
||||
'<prefix:tag2 xmlns:prefix="http://prefixed-namespace.com">content</prefix:tag2>'
|
||||
'<prefix2:tag3 xmlns:prefix2="http://another-namespace.com">'
|
||||
'<subtag xmlns="http://another-unprefixed-namespace.com">'
|
||||
'<subsubtag xmlns="http://yet-another-unprefixed-namespace.com">'
|
||||
'</prefix2:tag3>'
|
||||
'</root>'
|
||||
)
|
||||
|
||||
# The BeautifulSoup object includes every namespace prefix
|
||||
# defined in the entire document. This is the default set of
|
||||
# namespaces used by soupsieve.
|
||||
#
|
||||
# Un-prefixed namespaces are not included, and if a given
|
||||
# prefix is defined twice, only the first prefix encountered
|
||||
# in the document shows up here.
|
||||
assert soup._namespaces == {
|
||||
'xml': 'http://www.w3.org/XML/1998/namespace',
|
||||
'prefix': 'http://prefixed-namespace.com',
|
||||
'prefix2': 'http://another-namespace.com'
|
||||
}
|
||||
|
||||
# A Tag object includes only the namespace prefixes
|
||||
# that were in scope when it was parsed.
|
||||
|
||||
# We do not track un-prefixed namespaces as we can only hold
|
||||
# one (the first one), and it will be recognized as the
|
||||
# default namespace by soupsieve, even when operating from a
|
||||
# tag with a different un-prefixed namespace.
|
||||
assert soup.tag._namespaces == {
|
||||
'xml': 'http://www.w3.org/XML/1998/namespace',
|
||||
}
|
||||
|
||||
assert soup.tag2._namespaces == {
|
||||
'prefix': 'http://prefixed-namespace.com',
|
||||
'xml': 'http://www.w3.org/XML/1998/namespace',
|
||||
}
|
||||
|
||||
assert soup.subtag._namespaces == {
|
||||
'prefix2': 'http://another-namespace.com',
|
||||
'xml': 'http://www.w3.org/XML/1998/namespace',
|
||||
}
|
||||
|
||||
assert soup.subsubtag._namespaces == {
|
||||
'prefix2': 'http://another-namespace.com',
|
||||
'xml': 'http://www.w3.org/XML/1998/namespace',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not SOUP_SIEVE_PRESENT, reason="Soup Sieve not installed"
|
||||
)
|
||||
def test_namespace_interaction_with_select_and_find(self):
|
||||
# Demonstrate how namespaces interact with select* and
|
||||
# find* methods.
|
||||
|
||||
soup = self.soup(
|
||||
'<?xml version="1.1"?>\n'
|
||||
'<root>'
|
||||
'<tag xmlns="http://unprefixed-namespace.com">content</tag>'
|
||||
'<prefix:tag2 xmlns:prefix="http://prefixed-namespace.com">content</tag>'
|
||||
'<subtag xmlns:prefix="http://another-namespace-same-prefix.com">'
|
||||
'<prefix:tag3>'
|
||||
'</subtag>'
|
||||
'</root>'
|
||||
)
|
||||
|
||||
# soupselect uses namespace URIs.
|
||||
assert soup.select_one('tag').name == 'tag'
|
||||
assert soup.select_one('prefix|tag2').name == 'tag2'
|
||||
|
||||
# If a prefix is declared more than once, only the first usage
|
||||
# is registered with the BeautifulSoup object.
|
||||
assert soup.select_one('prefix|tag3') is None
|
||||
|
||||
# But you can always explicitly specify a namespace dictionary.
|
||||
assert soup.select_one(
|
||||
'prefix|tag3', namespaces=soup.subtag._namespaces
|
||||
).name == 'tag3'
|
||||
|
||||
# And a Tag (as opposed to the BeautifulSoup object) will
|
||||
# have a set of default namespaces scoped to that Tag.
|
||||
assert soup.subtag.select_one('prefix|tag3').name=='tag3'
|
||||
|
||||
# the find() methods aren't fully namespace-aware; they just
|
||||
# look at prefixes.
|
||||
assert soup.find('tag').name == 'tag'
|
||||
assert soup.find('prefix:tag2').name == 'tag2'
|
||||
assert soup.find('prefix:tag3').name == 'tag3'
|
||||
assert soup.subtag.find('prefix:tag3').name == 'tag3'
|
||||
|
||||
def test_pickle_restores_builder(self):
|
||||
# The lxml TreeBuilder is not picklable, so when unpickling
|
||||
# a document created with it, a new TreeBuilder of the
|
||||
# appropriate class is created.
|
||||
soup = self.soup("<a>some markup</a>")
|
||||
assert isinstance(soup.builder, self.default_builder)
|
||||
pickled = pickle.dumps(soup)
|
||||
unpickled = pickle.loads(pickled)
|
||||
|
||||
assert "some markup" == unpickled.a.string
|
||||
assert unpickled.builder != soup.builder
|
||||
assert isinstance(unpickled.builder, self.default_builder)
|
||||
@@ -0,0 +1,144 @@
|
||||
import pytest
|
||||
|
||||
from bs4.element import (
|
||||
CData,
|
||||
Comment,
|
||||
Declaration,
|
||||
Doctype,
|
||||
NavigableString,
|
||||
RubyParenthesisString,
|
||||
RubyTextString,
|
||||
Script,
|
||||
Stylesheet,
|
||||
TemplateString,
|
||||
)
|
||||
|
||||
from . import SoupTest
|
||||
|
||||
class TestNavigableString(SoupTest):
|
||||
|
||||
def test_text_acquisition_methods(self):
|
||||
# These methods are intended for use against Tag, but they
|
||||
# work on NavigableString as well,
|
||||
|
||||
s = NavigableString("fee ")
|
||||
cdata = CData("fie ")
|
||||
comment = Comment("foe ")
|
||||
|
||||
assert "fee " == s.get_text()
|
||||
assert "fee" == s.get_text(strip=True)
|
||||
assert ["fee "] == list(s.strings)
|
||||
assert ["fee"] == list(s.stripped_strings)
|
||||
assert ["fee "] == list(s._all_strings())
|
||||
|
||||
assert "fie " == cdata.get_text()
|
||||
assert "fie" == cdata.get_text(strip=True)
|
||||
assert ["fie "] == list(cdata.strings)
|
||||
assert ["fie"] == list(cdata.stripped_strings)
|
||||
assert ["fie "] == list(cdata._all_strings())
|
||||
|
||||
# Since a Comment isn't normally considered 'text',
|
||||
# these methods generally do nothing.
|
||||
assert "" == comment.get_text()
|
||||
assert [] == list(comment.strings)
|
||||
assert [] == list(comment.stripped_strings)
|
||||
assert [] == list(comment._all_strings())
|
||||
|
||||
# Unless you specifically say that comments are okay.
|
||||
assert "foe" == comment.get_text(strip=True, types=Comment)
|
||||
assert "foe " == comment.get_text(types=(Comment, NavigableString))
|
||||
|
||||
def test_string_has_immutable_name_property(self):
|
||||
# string.name is defined as None and can't be modified
|
||||
string = self.soup("s").string
|
||||
assert None == string.name
|
||||
with pytest.raises(AttributeError):
|
||||
string.name = 'foo'
|
||||
|
||||
class TestNavigableStringSubclasses(SoupTest):
|
||||
|
||||
def test_cdata(self):
|
||||
# None of the current builders turn CDATA sections into CData
|
||||
# objects, but you can create them manually.
|
||||
soup = self.soup("")
|
||||
cdata = CData("foo")
|
||||
soup.insert(1, cdata)
|
||||
assert str(soup) == "<![CDATA[foo]]>"
|
||||
assert soup.find(string="foo") == "foo"
|
||||
assert soup.contents[0] == "foo"
|
||||
|
||||
def test_cdata_is_never_formatted(self):
|
||||
"""Text inside a CData object is passed into the formatter.
|
||||
|
||||
But the return value is ignored.
|
||||
"""
|
||||
|
||||
self.count = 0
|
||||
def increment(*args):
|
||||
self.count += 1
|
||||
return "BITTER FAILURE"
|
||||
|
||||
soup = self.soup("")
|
||||
cdata = CData("<><><>")
|
||||
soup.insert(1, cdata)
|
||||
assert b"<![CDATA[<><><>]]>" == soup.encode(formatter=increment)
|
||||
assert 1 == self.count
|
||||
|
||||
def test_doctype_ends_in_newline(self):
|
||||
# Unlike other NavigableString subclasses, a DOCTYPE always ends
|
||||
# in a newline.
|
||||
doctype = Doctype("foo")
|
||||
soup = self.soup("")
|
||||
soup.insert(1, doctype)
|
||||
assert soup.encode() == b"<!DOCTYPE foo>\n"
|
||||
|
||||
def test_declaration(self):
|
||||
d = Declaration("foo")
|
||||
assert "<?foo?>" == d.output_ready()
|
||||
|
||||
def test_default_string_containers(self):
|
||||
# In some cases, we use different NavigableString subclasses for
|
||||
# the same text in different tags.
|
||||
soup = self.soup(
|
||||
"<div>text</div><script>text</script><style>text</style>"
|
||||
)
|
||||
assert [NavigableString, Script, Stylesheet] == [
|
||||
x.__class__ for x in soup.find_all(string=True)
|
||||
]
|
||||
|
||||
# The TemplateString is a little unusual because it's generally found
|
||||
# _inside_ children of a <template> element, not a direct child of the
|
||||
# <template> element.
|
||||
soup = self.soup(
|
||||
"<template>Some text<p>In a tag</p></template>Some text outside"
|
||||
)
|
||||
assert all(
|
||||
isinstance(x, TemplateString)
|
||||
for x in soup.template._all_strings(types=None)
|
||||
)
|
||||
|
||||
# Once the <template> tag closed, we went back to using
|
||||
# NavigableString.
|
||||
outside = soup.template.next_sibling
|
||||
assert isinstance(outside, NavigableString)
|
||||
assert not isinstance(outside, TemplateString)
|
||||
|
||||
# The TemplateString is also unusual because it can contain
|
||||
# NavigableString subclasses of _other_ types, such as
|
||||
# Comment.
|
||||
markup = b"<template>Some text<p>In a tag</p><!--with a comment--></template>"
|
||||
soup = self.soup(markup)
|
||||
assert markup == soup.template.encode("utf8")
|
||||
|
||||
def test_ruby_strings(self):
|
||||
markup = "<ruby>漢 <rp>(</rp><rt>kan</rt><rp>)</rp> 字 <rp>(</rp><rt>ji</rt><rp>)</rp></ruby>"
|
||||
soup = self.soup(markup)
|
||||
assert isinstance(soup.rp.string, RubyParenthesisString)
|
||||
assert isinstance(soup.rt.string, RubyTextString)
|
||||
|
||||
# Just as a demo, here's what this means for get_text usage.
|
||||
assert "漢字" == soup.get_text(strip=True)
|
||||
assert "漢(kan)字(ji)" == soup.get_text(
|
||||
strip=True,
|
||||
types=(NavigableString, RubyTextString, RubyParenthesisString)
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Tests of the bs4.element.PageElement class"""
|
||||
import copy
|
||||
import pickle
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
ResultSet,
|
||||
SoupStrainer,
|
||||
)
|
||||
from . import (
|
||||
SoupTest,
|
||||
)
|
||||
|
||||
class TestEncoding(SoupTest):
|
||||
"""Test the ability to encode objects into strings."""
|
||||
|
||||
def test_unicode_string_can_be_encoded(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
assert soup.b.string.encode("utf-8") == "\N{SNOWMAN}".encode("utf-8")
|
||||
|
||||
def test_tag_containing_unicode_string_can_be_encoded(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
assert soup.b.encode("utf-8") == html.encode("utf-8")
|
||||
|
||||
def test_encoding_substitutes_unrecognized_characters_by_default(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
assert soup.b.encode("ascii") == b"<b>☃</b>"
|
||||
|
||||
def test_encoding_can_be_made_strict(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
with pytest.raises(UnicodeEncodeError):
|
||||
soup.encode("ascii", errors="strict")
|
||||
|
||||
def test_decode_contents(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
assert "\N{SNOWMAN}" == soup.b.decode_contents()
|
||||
|
||||
def test_encode_contents(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
assert "\N{SNOWMAN}".encode("utf8") == soup.b.encode_contents(
|
||||
encoding="utf8"
|
||||
)
|
||||
|
||||
def test_encode_deeply_nested_document(self):
|
||||
# This test verifies that encoding a string doesn't involve
|
||||
# any recursive function calls. If it did, this test would
|
||||
# overflow the Python interpreter stack.
|
||||
limit = sys.getrecursionlimit() + 1
|
||||
markup = "<span>" * limit
|
||||
soup = self.soup(markup)
|
||||
encoded = soup.encode()
|
||||
assert limit == encoded.count(b"<span>")
|
||||
|
||||
def test_deprecated_renderContents(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
soup.renderContents()
|
||||
assert "\N{SNOWMAN}".encode("utf8") == soup.b.renderContents()
|
||||
|
||||
def test_repr(self):
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
assert html == repr(soup)
|
||||
|
||||
|
||||
class TestFormatters(SoupTest):
|
||||
"""Test the formatting feature, used by methods like decode() and
|
||||
prettify(), and the formatters themselves.
|
||||
"""
|
||||
|
||||
def test_default_formatter_is_minimal(self):
|
||||
markup = "<b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
soup = self.soup(markup)
|
||||
decoded = soup.decode(formatter="minimal")
|
||||
# The < is converted back into < but the e-with-acute is left alone.
|
||||
assert decoded == self.document_for(
|
||||
"<b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
)
|
||||
|
||||
def test_formatter_html(self):
|
||||
markup = "<br><b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
soup = self.soup(markup)
|
||||
decoded = soup.decode(formatter="html")
|
||||
assert decoded == self.document_for(
|
||||
"<br/><b><<Sacré bleu!>></b>"
|
||||
)
|
||||
|
||||
def test_formatter_html5(self):
|
||||
markup = "<br><b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
soup = self.soup(markup)
|
||||
decoded = soup.decode(formatter="html5")
|
||||
assert decoded == self.document_for(
|
||||
"<br><b><<Sacré bleu!>></b>"
|
||||
)
|
||||
|
||||
def test_formatter_minimal(self):
|
||||
markup = "<b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
soup = self.soup(markup)
|
||||
decoded = soup.decode(formatter="minimal")
|
||||
# The < is converted back into < but the e-with-acute is left alone.
|
||||
assert decoded == self.document_for(
|
||||
"<b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
)
|
||||
|
||||
def test_formatter_null(self):
|
||||
markup = "<b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
soup = self.soup(markup)
|
||||
decoded = soup.decode(formatter=None)
|
||||
# Neither the angle brackets nor the e-with-acute are converted.
|
||||
# This is not valid HTML, but it's what the user wanted.
|
||||
assert decoded == self.document_for(
|
||||
"<b><<Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></b>"
|
||||
)
|
||||
|
||||
def test_formatter_custom(self):
|
||||
markup = "<b><foo></b><b>bar</b><br/>"
|
||||
soup = self.soup(markup)
|
||||
decoded = soup.decode(formatter = lambda x: x.upper())
|
||||
# Instead of normal entity conversion code, the custom
|
||||
# callable is called on every string.
|
||||
assert decoded == self.document_for("<b><FOO></b><b>BAR</b><br/>")
|
||||
|
||||
def test_formatter_is_run_on_attribute_values(self):
|
||||
markup = '<a href="http://a.com?a=b&c=é">e</a>'
|
||||
soup = self.soup(markup)
|
||||
a = soup.a
|
||||
|
||||
expect_minimal = '<a href="http://a.com?a=b&c=é">e</a>'
|
||||
|
||||
assert expect_minimal == a.decode()
|
||||
assert expect_minimal == a.decode(formatter="minimal")
|
||||
|
||||
expect_html = '<a href="http://a.com?a=b&c=é">e</a>'
|
||||
assert expect_html == a.decode(formatter="html")
|
||||
|
||||
assert markup == a.decode(formatter=None)
|
||||
expect_upper = '<a href="HTTP://A.COM?A=B&C=É">E</a>'
|
||||
assert expect_upper == a.decode(formatter=lambda x: x.upper())
|
||||
|
||||
def test_formatter_skips_script_tag_for_html_documents(self):
|
||||
doc = """
|
||||
<script type="text/javascript">
|
||||
console.log("< < hey > > ");
|
||||
</script>
|
||||
"""
|
||||
encoded = BeautifulSoup(doc, 'html.parser').encode()
|
||||
assert b"< < hey > >" in encoded
|
||||
|
||||
def test_formatter_skips_style_tag_for_html_documents(self):
|
||||
doc = """
|
||||
<style type="text/css">
|
||||
console.log("< < hey > > ");
|
||||
</style>
|
||||
"""
|
||||
encoded = BeautifulSoup(doc, 'html.parser').encode()
|
||||
assert b"< < hey > >" in encoded
|
||||
|
||||
def test_prettify_leaves_preformatted_text_alone(self):
|
||||
soup = self.soup("<div> foo <pre> \tbar\n \n </pre> baz <textarea> eee\nfff\t</textarea></div>")
|
||||
# Everything outside the <pre> tag is reformatted, but everything
|
||||
# inside is left alone.
|
||||
assert '<div>\n foo\n <pre> \tbar\n \n </pre>\n baz\n <textarea> eee\nfff\t</textarea>\n</div>\n' == soup.div.prettify()
|
||||
|
||||
def test_prettify_handles_nested_string_literal_tags(self):
|
||||
# Most of this markup is inside a <pre> tag, so prettify()
|
||||
# only does three things to it:
|
||||
# 1. Add a newline and a space between the <div> and the <pre>
|
||||
# 2. Add a newline after the </pre>
|
||||
# 3. Add a newline at the end.
|
||||
#
|
||||
# The contents of the <pre> tag are left completely alone. In
|
||||
# particular, we don't start adding whitespace again once we
|
||||
# encounter the first </pre> tag, because we know it's not
|
||||
# the one that put us into string literal mode.
|
||||
markup = """<div><pre><code>some
|
||||
<script><pre>code</pre></script> for you
|
||||
</code></pre></div>"""
|
||||
|
||||
expect = """<div>
|
||||
<pre><code>some
|
||||
<script><pre>code</pre></script> for you
|
||||
</code></pre>
|
||||
</div>
|
||||
"""
|
||||
soup = self.soup(markup)
|
||||
assert expect == soup.div.prettify()
|
||||
|
||||
def test_prettify_accepts_formatter_function(self):
|
||||
soup = BeautifulSoup("<html><body>foo</body></html>", 'html.parser')
|
||||
pretty = soup.prettify(formatter = lambda x: x.upper())
|
||||
assert "FOO" in pretty
|
||||
|
||||
def test_prettify_outputs_unicode_by_default(self):
|
||||
soup = self.soup("<a></a>")
|
||||
assert str == type(soup.prettify())
|
||||
|
||||
def test_prettify_can_encode_data(self):
|
||||
soup = self.soup("<a></a>")
|
||||
assert bytes == type(soup.prettify("utf-8"))
|
||||
|
||||
def test_html_entity_substitution_off_by_default(self):
|
||||
markup = "<b>Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!</b>"
|
||||
soup = self.soup(markup)
|
||||
encoded = soup.b.encode("utf-8")
|
||||
assert encoded == markup.encode('utf-8')
|
||||
|
||||
def test_encoding_substitution(self):
|
||||
# Here's the <meta> tag saying that a document is
|
||||
# encoded in Shift-JIS.
|
||||
meta_tag = ('<meta content="text/html; charset=x-sjis" '
|
||||
'http-equiv="Content-type"/>')
|
||||
soup = self.soup(meta_tag)
|
||||
|
||||
# Parse the document, and the charset apprears unchanged.
|
||||
assert soup.meta['content'] == 'text/html; charset=x-sjis'
|
||||
|
||||
# Encode the document into some encoding, and the encoding is
|
||||
# substituted into the meta tag.
|
||||
utf_8 = soup.encode("utf-8")
|
||||
assert b"charset=utf-8" in utf_8
|
||||
|
||||
euc_jp = soup.encode("euc_jp")
|
||||
assert b"charset=euc_jp" in euc_jp
|
||||
|
||||
shift_jis = soup.encode("shift-jis")
|
||||
assert b"charset=shift-jis" in shift_jis
|
||||
|
||||
utf_16_u = soup.encode("utf-16").decode("utf-16")
|
||||
assert "charset=utf-16" in utf_16_u
|
||||
|
||||
def test_encoding_substitution_doesnt_happen_if_tag_is_strained(self):
|
||||
markup = ('<head><meta content="text/html; charset=x-sjis" '
|
||||
'http-equiv="Content-type"/></head><pre>foo</pre>')
|
||||
|
||||
# Beautiful Soup used to try to rewrite the meta tag even if the
|
||||
# meta tag got filtered out by the strainer. This test makes
|
||||
# sure that doesn't happen.
|
||||
strainer = SoupStrainer('pre')
|
||||
soup = self.soup(markup, parse_only=strainer)
|
||||
assert soup.contents[0].name == 'pre'
|
||||
|
||||
|
||||
class TestPersistence(SoupTest):
|
||||
"Testing features like pickle and deepcopy."
|
||||
|
||||
def setup_method(self):
|
||||
self.page = """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/REC-html40/transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<title>Beautiful Soup: We called him Tortoise because he taught us.</title>
|
||||
<link rev="made" href="mailto:leonardr@segfault.org">
|
||||
<meta name="Description" content="Beautiful Soup: an HTML parser optimized for screen-scraping.">
|
||||
<meta name="generator" content="Markov Approximation 1.4 (module: leonardr)">
|
||||
<meta name="author" content="Leonard Richardson">
|
||||
</head>
|
||||
<body>
|
||||
<a href="foo">foo</a>
|
||||
<a href="foo"><b>bar</b></a>
|
||||
</body>
|
||||
</html>"""
|
||||
self.tree = self.soup(self.page)
|
||||
|
||||
def test_pickle_and_unpickle_identity(self):
|
||||
# Pickling a tree, then unpickling it, yields a tree identical
|
||||
# to the original.
|
||||
dumped = pickle.dumps(self.tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
assert loaded.__class__ == BeautifulSoup
|
||||
assert loaded.decode() == self.tree.decode()
|
||||
|
||||
def test_deepcopy_identity(self):
|
||||
# Making a deepcopy of a tree yields an identical tree.
|
||||
copied = copy.deepcopy(self.tree)
|
||||
assert copied.decode() == self.tree.decode()
|
||||
|
||||
def test_copy_deeply_nested_document(self):
|
||||
# This test verifies that copy and deepcopy don't involve any
|
||||
# recursive function calls. If they did, this test would
|
||||
# overflow the Python interpreter stack.
|
||||
limit = sys.getrecursionlimit() + 1
|
||||
markup = "<span>" * limit
|
||||
|
||||
soup = self.soup(markup)
|
||||
|
||||
copied = copy.copy(soup)
|
||||
copied = copy.deepcopy(soup)
|
||||
|
||||
def test_copy_preserves_encoding(self):
|
||||
soup = BeautifulSoup(b'<p> </p>', 'html.parser')
|
||||
encoding = soup.original_encoding
|
||||
copy = soup.__copy__()
|
||||
assert "<p> </p>" == str(copy)
|
||||
assert encoding == copy.original_encoding
|
||||
|
||||
def test_copy_preserves_builder_information(self):
|
||||
|
||||
tag = self.soup('<p></p>').p
|
||||
|
||||
# Simulate a tag obtained from a source file.
|
||||
tag.sourceline = 10
|
||||
tag.sourcepos = 33
|
||||
|
||||
copied = tag.__copy__()
|
||||
|
||||
# The TreeBuilder object is no longer availble, but information
|
||||
# obtained from it gets copied over to the new Tag object.
|
||||
assert tag.sourceline == copied.sourceline
|
||||
assert tag.sourcepos == copied.sourcepos
|
||||
assert tag.can_be_empty_element == copied.can_be_empty_element
|
||||
assert tag.cdata_list_attributes == copied.cdata_list_attributes
|
||||
assert tag.preserve_whitespace_tags == copied.preserve_whitespace_tags
|
||||
assert tag.interesting_string_types == copied.interesting_string_types
|
||||
|
||||
def test_unicode_pickle(self):
|
||||
# A tree containing Unicode characters can be pickled.
|
||||
html = "<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
dumped = pickle.dumps(soup, pickle.HIGHEST_PROTOCOL)
|
||||
loaded = pickle.loads(dumped)
|
||||
assert loaded.decode() == soup.decode()
|
||||
|
||||
def test_copy_navigablestring_is_not_attached_to_tree(self):
|
||||
html = "<b>Foo<a></a></b><b>Bar</b>"
|
||||
soup = self.soup(html)
|
||||
s1 = soup.find(string="Foo")
|
||||
s2 = copy.copy(s1)
|
||||
assert s1 == s2
|
||||
assert None == s2.parent
|
||||
assert None == s2.next_element
|
||||
assert None != s1.next_sibling
|
||||
assert None == s2.next_sibling
|
||||
assert None == s2.previous_element
|
||||
|
||||
def test_copy_navigablestring_subclass_has_same_type(self):
|
||||
html = "<b><!--Foo--></b>"
|
||||
soup = self.soup(html)
|
||||
s1 = soup.string
|
||||
s2 = copy.copy(s1)
|
||||
assert s1 == s2
|
||||
assert isinstance(s2, Comment)
|
||||
|
||||
def test_copy_entire_soup(self):
|
||||
html = "<div><b>Foo<a></a></b><b>Bar</b></div>end"
|
||||
soup = self.soup(html)
|
||||
soup_copy = copy.copy(soup)
|
||||
assert soup == soup_copy
|
||||
|
||||
def test_copy_tag_copies_contents(self):
|
||||
html = "<div><b>Foo<a></a></b><b>Bar</b></div>end"
|
||||
soup = self.soup(html)
|
||||
div = soup.div
|
||||
div_copy = copy.copy(div)
|
||||
|
||||
# The two tags look the same, and evaluate to equal.
|
||||
assert str(div) == str(div_copy)
|
||||
assert div == div_copy
|
||||
|
||||
# But they're not the same object.
|
||||
assert div is not div_copy
|
||||
|
||||
# And they don't have the same relation to the parse tree. The
|
||||
# copy is not associated with a parse tree at all.
|
||||
assert None == div_copy.parent
|
||||
assert None == div_copy.previous_element
|
||||
assert None == div_copy.find(string='Bar').next_element
|
||||
assert None != div.find(string='Bar').next_element
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests of Beautiful Soup as a whole."""
|
||||
|
||||
from pdb import set_trace
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import pytest
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from bs4 import (
|
||||
BeautifulSoup,
|
||||
BeautifulStoneSoup,
|
||||
GuessedAtParserWarning,
|
||||
MarkupResemblesLocatorWarning,
|
||||
dammit,
|
||||
)
|
||||
from bs4.builder import (
|
||||
builder_registry,
|
||||
TreeBuilder,
|
||||
ParserRejectedMarkup,
|
||||
)
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
SoupStrainer,
|
||||
PYTHON_SPECIFIC_ENCODINGS,
|
||||
Tag,
|
||||
NavigableString,
|
||||
)
|
||||
|
||||
from . import (
|
||||
default_builder,
|
||||
LXML_PRESENT,
|
||||
SoupTest,
|
||||
)
|
||||
import warnings
|
||||
|
||||
class TestConstructor(SoupTest):
|
||||
|
||||
def test_short_unicode_input(self):
|
||||
data = "<h1>éé</h1>"
|
||||
soup = self.soup(data)
|
||||
assert "éé" == soup.h1.string
|
||||
|
||||
def test_embedded_null(self):
|
||||
data = "<h1>foo\0bar</h1>"
|
||||
soup = self.soup(data)
|
||||
assert "foo\0bar" == soup.h1.string
|
||||
|
||||
def test_exclude_encodings(self):
|
||||
utf8_data = "Räksmörgås".encode("utf-8")
|
||||
soup = self.soup(utf8_data, exclude_encodings=["utf-8"])
|
||||
assert "windows-1252" == soup.original_encoding
|
||||
|
||||
def test_custom_builder_class(self):
|
||||
# Verify that you can pass in a custom Builder class and
|
||||
# it'll be instantiated with the appropriate keyword arguments.
|
||||
class Mock(object):
|
||||
def __init__(self, **kwargs):
|
||||
self.called_with = kwargs
|
||||
self.is_xml = True
|
||||
self.store_line_numbers = False
|
||||
self.cdata_list_attributes = []
|
||||
self.preserve_whitespace_tags = []
|
||||
self.string_containers = {}
|
||||
def initialize_soup(self, soup):
|
||||
pass
|
||||
def feed(self, markup):
|
||||
self.fed = markup
|
||||
def reset(self):
|
||||
pass
|
||||
def ignore(self, ignore):
|
||||
pass
|
||||
set_up_substitutions = can_be_empty_element = ignore
|
||||
def prepare_markup(self, *args, **kwargs):
|
||||
yield "prepared markup", "original encoding", "declared encoding", "contains replacement characters"
|
||||
|
||||
kwargs = dict(
|
||||
var="value",
|
||||
# This is a deprecated BS3-era keyword argument, which
|
||||
# will be stripped out.
|
||||
convertEntities=True,
|
||||
)
|
||||
with warnings.catch_warnings(record=True):
|
||||
soup = BeautifulSoup('', builder=Mock, **kwargs)
|
||||
assert isinstance(soup.builder, Mock)
|
||||
assert dict(var="value") == soup.builder.called_with
|
||||
assert "prepared markup" == soup.builder.fed
|
||||
|
||||
# You can also instantiate the TreeBuilder yourself. In this
|
||||
# case, that specific object is used and any keyword arguments
|
||||
# to the BeautifulSoup constructor are ignored.
|
||||
builder = Mock(**kwargs)
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulSoup(
|
||||
'', builder=builder, ignored_value=True,
|
||||
)
|
||||
msg = str(w[0].message)
|
||||
assert msg.startswith("Keyword arguments to the BeautifulSoup constructor will be ignored.")
|
||||
assert builder == soup.builder
|
||||
assert kwargs == builder.called_with
|
||||
|
||||
def test_parser_markup_rejection(self):
|
||||
# If markup is completely rejected by the parser, an
|
||||
# explanatory ParserRejectedMarkup exception is raised.
|
||||
class Mock(TreeBuilder):
|
||||
def feed(self, *args, **kwargs):
|
||||
raise ParserRejectedMarkup("Nope.")
|
||||
|
||||
def prepare_markup(self, *args, **kwargs):
|
||||
# We're going to try two different ways of preparing this markup,
|
||||
# but feed() will reject both of them.
|
||||
yield markup, None, None, False
|
||||
yield markup, None, None, False
|
||||
|
||||
|
||||
import re
|
||||
with pytest.raises(ParserRejectedMarkup) as exc_info:
|
||||
BeautifulSoup('', builder=Mock)
|
||||
assert "The markup you provided was rejected by the parser. Trying a different parser or a different encoding may help." in str(exc_info.value)
|
||||
|
||||
def test_cdata_list_attributes(self):
|
||||
# Most attribute values are represented as scalars, but the
|
||||
# HTML standard says that some attributes, like 'class' have
|
||||
# space-separated lists as values.
|
||||
markup = '<a id=" an id " class=" a class "></a>'
|
||||
soup = self.soup(markup)
|
||||
|
||||
# Note that the spaces are stripped for 'class' but not for 'id'.
|
||||
a = soup.a
|
||||
assert " an id " == a['id']
|
||||
assert ["a", "class"] == a['class']
|
||||
|
||||
# TreeBuilder takes an argument called 'multi_valued_attributes' which lets
|
||||
# you customize or disable this. As always, you can customize the TreeBuilder
|
||||
# by passing in a keyword argument to the BeautifulSoup constructor.
|
||||
soup = self.soup(markup, builder=default_builder, multi_valued_attributes=None)
|
||||
assert " a class " == soup.a['class']
|
||||
|
||||
# Here are two ways of saying that `id` is a multi-valued
|
||||
# attribute in this context, but 'class' is not.
|
||||
for switcheroo in ({'*': 'id'}, {'a': 'id'}):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
# This will create a warning about not explicitly
|
||||
# specifying a parser, but we'll ignore it.
|
||||
soup = self.soup(markup, builder=None, multi_valued_attributes=switcheroo)
|
||||
a = soup.a
|
||||
assert ["an", "id"] == a['id']
|
||||
assert " a class " == a['class']
|
||||
|
||||
def test_replacement_classes(self):
|
||||
# Test the ability to pass in replacements for element classes
|
||||
# which will be used when building the tree.
|
||||
class TagPlus(Tag):
|
||||
pass
|
||||
|
||||
class StringPlus(NavigableString):
|
||||
pass
|
||||
|
||||
class CommentPlus(Comment):
|
||||
pass
|
||||
|
||||
soup = self.soup(
|
||||
"<a><b>foo</b>bar</a><!--whee-->",
|
||||
element_classes = {
|
||||
Tag: TagPlus,
|
||||
NavigableString: StringPlus,
|
||||
Comment: CommentPlus,
|
||||
}
|
||||
)
|
||||
|
||||
# The tree was built with TagPlus, StringPlus, and CommentPlus objects,
|
||||
# rather than Tag, String, and Comment objects.
|
||||
assert all(
|
||||
isinstance(x, (TagPlus, StringPlus, CommentPlus))
|
||||
for x in soup.recursiveChildGenerator()
|
||||
)
|
||||
|
||||
def test_alternate_string_containers(self):
|
||||
# Test the ability to customize the string containers for
|
||||
# different types of tags.
|
||||
class PString(NavigableString):
|
||||
pass
|
||||
|
||||
class BString(NavigableString):
|
||||
pass
|
||||
|
||||
soup = self.soup(
|
||||
"<div>Hello.<p>Here is <b>some <i>bolded</i></b> text",
|
||||
string_containers = {
|
||||
'b': BString,
|
||||
'p': PString,
|
||||
}
|
||||
)
|
||||
|
||||
# The string before the <p> tag is a regular NavigableString.
|
||||
assert isinstance(soup.div.contents[0], NavigableString)
|
||||
|
||||
# The string inside the <p> tag, but not inside the <i> tag,
|
||||
# is a PString.
|
||||
assert isinstance(soup.p.contents[0], PString)
|
||||
|
||||
# Every string inside the <b> tag is a BString, even the one that
|
||||
# was also inside an <i> tag.
|
||||
for s in soup.b.strings:
|
||||
assert isinstance(s, BString)
|
||||
|
||||
# Now that parsing was complete, the string_container_stack
|
||||
# (where this information was kept) has been cleared out.
|
||||
assert [] == soup.string_container_stack
|
||||
|
||||
|
||||
class TestOutput(SoupTest):
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"eventual_encoding,actual_encoding", [
|
||||
("utf-8", "utf-8"),
|
||||
("utf-16", "utf-16"),
|
||||
]
|
||||
)
|
||||
def test_decode_xml_declaration(self, eventual_encoding, actual_encoding):
|
||||
# Most of the time, calling decode() on an XML document will
|
||||
# give you a document declaration that mentions the encoding
|
||||
# you intend to use when encoding the document as a
|
||||
# bytestring.
|
||||
soup = self.soup("<tag></tag>")
|
||||
soup.is_xml = True
|
||||
assert (f'<?xml version="1.0" encoding="{actual_encoding}"?>\n<tag></tag>'
|
||||
== soup.decode(eventual_encoding=eventual_encoding))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"eventual_encoding", [x for x in PYTHON_SPECIFIC_ENCODINGS] + [None]
|
||||
)
|
||||
def test_decode_xml_declaration_with_missing_or_python_internal_eventual_encoding(self, eventual_encoding):
|
||||
# But if you pass a Python internal encoding into decode(), or
|
||||
# omit the eventual_encoding altogether, the document
|
||||
# declaration won't mention any particular encoding.
|
||||
soup = BeautifulSoup("<tag></tag>", "html.parser")
|
||||
soup.is_xml = True
|
||||
assert (f'<?xml version="1.0"?>\n<tag></tag>'
|
||||
== soup.decode(eventual_encoding=eventual_encoding))
|
||||
|
||||
def test(self):
|
||||
# BeautifulSoup subclasses Tag and extends the decode() method.
|
||||
# Make sure the other Tag methods which call decode() call
|
||||
# it correctly.
|
||||
soup = self.soup("<tag></tag>")
|
||||
assert b"<tag></tag>" == soup.encode(encoding="utf-8")
|
||||
assert b"<tag></tag>" == soup.encode_contents(encoding="utf-8")
|
||||
assert "<tag></tag>" == soup.decode_contents()
|
||||
assert "<tag>\n</tag>\n" == soup.prettify()
|
||||
|
||||
|
||||
class TestWarnings(SoupTest):
|
||||
# Note that some of the tests in this class create BeautifulSoup
|
||||
# objects directly rather than using self.soup(). That's
|
||||
# because SoupTest.soup is defined in a different file,
|
||||
# which will throw off the assertion in _assert_warning
|
||||
# that the code that triggered the warning is in the same
|
||||
# file as the test.
|
||||
|
||||
def _assert_warning(self, warnings, cls):
|
||||
for w in warnings:
|
||||
if isinstance(w.message, cls):
|
||||
assert w.filename == __file__
|
||||
return w
|
||||
raise Exception("%s warning not found in %r" % (cls, warnings))
|
||||
|
||||
def _assert_no_parser_specified(self, w):
|
||||
warning = self._assert_warning(w, GuessedAtParserWarning)
|
||||
message = str(warning.message)
|
||||
assert message.startswith(BeautifulSoup.NO_PARSER_SPECIFIED_WARNING[:60])
|
||||
|
||||
def test_warning_if_no_parser_specified(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulSoup("<a><b></b></a>")
|
||||
self._assert_no_parser_specified(w)
|
||||
|
||||
def test_warning_if_parser_specified_too_vague(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulSoup("<a><b></b></a>", "html")
|
||||
self._assert_no_parser_specified(w)
|
||||
|
||||
def test_no_warning_if_explicit_parser_specified(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>")
|
||||
assert [] == w
|
||||
|
||||
def test_parseOnlyThese_renamed_to_parse_only(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulSoup(
|
||||
"<a><b></b></a>", "html.parser",
|
||||
parseOnlyThese=SoupStrainer("b"),
|
||||
)
|
||||
warning = self._assert_warning(w, DeprecationWarning)
|
||||
msg = str(warning.message)
|
||||
assert "parseOnlyThese" in msg
|
||||
assert "parse_only" in msg
|
||||
assert b"<b></b>" == soup.encode()
|
||||
|
||||
def test_fromEncoding_renamed_to_from_encoding(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
utf8 = b"\xc3\xa9"
|
||||
soup = BeautifulSoup(
|
||||
utf8, "html.parser", fromEncoding="utf8"
|
||||
)
|
||||
warning = self._assert_warning(w, DeprecationWarning)
|
||||
msg = str(warning.message)
|
||||
assert "fromEncoding" in msg
|
||||
assert "from_encoding" in msg
|
||||
assert "utf8" == soup.original_encoding
|
||||
|
||||
def test_unrecognized_keyword_argument(self):
|
||||
with pytest.raises(TypeError):
|
||||
self.soup("<a>", no_such_argument=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extension",
|
||||
['markup.html', 'markup.htm', 'markup.HTML', 'markup.txt',
|
||||
'markup.xhtml', 'markup.xml', "/home/user/file", "c:\\user\file"]
|
||||
)
|
||||
def test_resembles_filename_warning(self, extension):
|
||||
# A warning is issued if the "markup" looks like the name of
|
||||
# an HTML or text file, or a full path to a file on disk.
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulSoup("markup" + extension, "html.parser")
|
||||
warning = self._assert_warning(w, MarkupResemblesLocatorWarning)
|
||||
assert "looks more like a filename" in str(warning.message)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extension",
|
||||
['markuphtml', 'markup.com', '', 'markup.js']
|
||||
)
|
||||
def test_resembles_filename_no_warning(self, extension):
|
||||
# The 'looks more like a filename' warning is not issued if
|
||||
# the markup looks like a bare string, a domain name, or a
|
||||
# file that's not an HTML file.
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("markup" + extension)
|
||||
assert [] == w
|
||||
|
||||
def test_url_warning_with_bytes_url(self):
|
||||
url = b"http://www.crummybytes.com/"
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
soup = BeautifulSoup(url, "html.parser")
|
||||
warning = self._assert_warning(
|
||||
warning_list, MarkupResemblesLocatorWarning
|
||||
)
|
||||
assert "looks more like a URL" in str(warning.message)
|
||||
assert url not in str(warning.message).encode("utf8")
|
||||
|
||||
def test_url_warning_with_unicode_url(self):
|
||||
url = "http://www.crummyunicode.com/"
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
# note - this url must differ from the bytes one otherwise
|
||||
# python's warnings system swallows the second warning
|
||||
soup = BeautifulSoup(url, "html.parser")
|
||||
warning = self._assert_warning(
|
||||
warning_list, MarkupResemblesLocatorWarning
|
||||
)
|
||||
assert "looks more like a URL" in str(warning.message)
|
||||
assert url not in str(warning.message)
|
||||
|
||||
def test_url_warning_with_bytes_and_space(self):
|
||||
# Here the markup contains something besides a URL, so no warning
|
||||
# is issued.
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
soup = self.soup(b"http://www.crummybytes.com/ is great")
|
||||
assert not any("looks more like a URL" in str(w.message)
|
||||
for w in warning_list)
|
||||
|
||||
def test_url_warning_with_unicode_and_space(self):
|
||||
with warnings.catch_warnings(record=True) as warning_list:
|
||||
soup = self.soup("http://www.crummyunicode.com/ is great")
|
||||
assert not any("looks more like a URL" in str(w.message)
|
||||
for w in warning_list)
|
||||
|
||||
|
||||
class TestSelectiveParsing(SoupTest):
|
||||
|
||||
def test_parse_with_soupstrainer(self):
|
||||
markup = "No<b>Yes</b><a>No<b>Yes <c>Yes</c></b>"
|
||||
strainer = SoupStrainer("b")
|
||||
soup = self.soup(markup, parse_only=strainer)
|
||||
assert soup.encode() == b"<b>Yes</b><b>Yes <c>Yes</c></b>"
|
||||
|
||||
|
||||
class TestNewTag(SoupTest):
|
||||
"""Test the BeautifulSoup.new_tag() method."""
|
||||
def test_new_tag(self):
|
||||
soup = self.soup("")
|
||||
new_tag = soup.new_tag("foo", bar="baz", attrs={"name": "a name"})
|
||||
assert isinstance(new_tag, Tag)
|
||||
assert "foo" == new_tag.name
|
||||
assert dict(bar="baz", name="a name") == new_tag.attrs
|
||||
assert None == new_tag.parent
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not LXML_PRESENT,
|
||||
reason="lxml not installed, cannot parse XML document"
|
||||
)
|
||||
def test_xml_tag_inherits_self_closing_rules_from_builder(self):
|
||||
xml_soup = BeautifulSoup("", "xml")
|
||||
xml_br = xml_soup.new_tag("br")
|
||||
xml_p = xml_soup.new_tag("p")
|
||||
|
||||
# Both the <br> and <p> tag are empty-element, just because
|
||||
# they have no contents.
|
||||
assert b"<br/>" == xml_br.encode()
|
||||
assert b"<p/>" == xml_p.encode()
|
||||
|
||||
def test_tag_inherits_self_closing_rules_from_builder(self):
|
||||
html_soup = BeautifulSoup("", "html.parser")
|
||||
html_br = html_soup.new_tag("br")
|
||||
html_p = html_soup.new_tag("p")
|
||||
|
||||
# The HTML builder users HTML's rules about which tags are
|
||||
# empty-element tags, and the new tags reflect these rules.
|
||||
assert b"<br/>" == html_br.encode()
|
||||
assert b"<p></p>" == html_p.encode()
|
||||
|
||||
class TestNewString(SoupTest):
|
||||
"""Test the BeautifulSoup.new_string() method."""
|
||||
def test_new_string_creates_navigablestring(self):
|
||||
soup = self.soup("")
|
||||
s = soup.new_string("foo")
|
||||
assert "foo" == s
|
||||
assert isinstance(s, NavigableString)
|
||||
|
||||
def test_new_string_can_create_navigablestring_subclass(self):
|
||||
soup = self.soup("")
|
||||
s = soup.new_string("foo", Comment)
|
||||
assert "foo" == s
|
||||
assert isinstance(s, Comment)
|
||||
|
||||
|
||||
class TestPickle(SoupTest):
|
||||
# Test our ability to pickle the BeautifulSoup object itself.
|
||||
|
||||
def test_normal_pickle(self):
|
||||
soup = self.soup("<a>some markup</a>")
|
||||
pickled = pickle.dumps(soup)
|
||||
unpickled = pickle.loads(pickled)
|
||||
assert "some markup" == unpickled.a.string
|
||||
|
||||
def test_pickle_with_no_builder(self):
|
||||
# We had a bug that prevented pickling from working if
|
||||
# the builder wasn't set.
|
||||
soup = self.soup("some markup")
|
||||
soup.builder = None
|
||||
pickled = pickle.dumps(soup)
|
||||
unpickled = pickle.loads(pickled)
|
||||
assert "some markup" == unpickled.string
|
||||
|
||||
class TestEncodingConversion(SoupTest):
|
||||
# Test Beautiful Soup's ability to decode and encode from various
|
||||
# encodings.
|
||||
|
||||
def setup_method(self):
|
||||
self.unicode_data = '<html><head><meta charset="utf-8"/></head><body><foo>Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!</foo></body></html>'
|
||||
self.utf8_data = self.unicode_data.encode("utf-8")
|
||||
# Just so you know what it looks like.
|
||||
assert self.utf8_data == b'<html><head><meta charset="utf-8"/></head><body><foo>Sacr\xc3\xa9 bleu!</foo></body></html>'
|
||||
|
||||
def test_ascii_in_unicode_out(self):
|
||||
# ASCII input is converted to Unicode. The original_encoding
|
||||
# attribute is set to 'utf-8', a superset of ASCII.
|
||||
chardet = dammit.chardet_dammit
|
||||
logging.disable(logging.WARNING)
|
||||
try:
|
||||
def noop(str):
|
||||
return None
|
||||
# Disable chardet, which will realize that the ASCII is ASCII.
|
||||
dammit.chardet_dammit = noop
|
||||
ascii = b"<foo>a</foo>"
|
||||
soup_from_ascii = self.soup(ascii)
|
||||
unicode_output = soup_from_ascii.decode()
|
||||
assert isinstance(unicode_output, str)
|
||||
assert unicode_output == self.document_for(ascii.decode())
|
||||
assert soup_from_ascii.original_encoding.lower() == "utf-8"
|
||||
finally:
|
||||
logging.disable(logging.NOTSET)
|
||||
dammit.chardet_dammit = chardet
|
||||
|
||||
def test_unicode_in_unicode_out(self):
|
||||
# Unicode input is left alone. The original_encoding attribute
|
||||
# is not set.
|
||||
soup_from_unicode = self.soup(self.unicode_data)
|
||||
assert soup_from_unicode.decode() == self.unicode_data
|
||||
assert soup_from_unicode.foo.string == 'Sacr\xe9 bleu!'
|
||||
assert soup_from_unicode.original_encoding == None
|
||||
|
||||
def test_utf8_in_unicode_out(self):
|
||||
# UTF-8 input is converted to Unicode. The original_encoding
|
||||
# attribute is set.
|
||||
soup_from_utf8 = self.soup(self.utf8_data)
|
||||
assert soup_from_utf8.decode() == self.unicode_data
|
||||
assert soup_from_utf8.foo.string == 'Sacr\xe9 bleu!'
|
||||
|
||||
def test_utf8_out(self):
|
||||
# The internal data structures can be encoded as UTF-8.
|
||||
soup_from_unicode = self.soup(self.unicode_data)
|
||||
assert soup_from_unicode.encode('utf-8') == self.utf8_data
|
||||
@@ -0,0 +1,234 @@
|
||||
import warnings
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
NavigableString,
|
||||
)
|
||||
from . import SoupTest
|
||||
|
||||
class TestTag(SoupTest):
|
||||
"""Test various methods of Tag which aren't so complicated they
|
||||
need their own classes.
|
||||
"""
|
||||
|
||||
def test__should_pretty_print(self):
|
||||
# Test the rules about when a tag should be pretty-printed.
|
||||
tag = self.soup("").new_tag("a_tag")
|
||||
|
||||
# No list of whitespace-preserving tags -> pretty-print
|
||||
tag._preserve_whitespace_tags = None
|
||||
assert True == tag._should_pretty_print(0)
|
||||
|
||||
# List exists but tag is not on the list -> pretty-print
|
||||
tag.preserve_whitespace_tags = ["some_other_tag"]
|
||||
assert True == tag._should_pretty_print(1)
|
||||
|
||||
# Indent level is None -> don't pretty-print
|
||||
assert False == tag._should_pretty_print(None)
|
||||
|
||||
# Tag is on the whitespace-preserving list -> don't pretty-print
|
||||
tag.preserve_whitespace_tags = ["some_other_tag", "a_tag"]
|
||||
assert False == tag._should_pretty_print(1)
|
||||
|
||||
def test_len(self):
|
||||
"""The length of a Tag is its number of children."""
|
||||
soup = self.soup("<top>1<b>2</b>3</top>")
|
||||
|
||||
# The BeautifulSoup object itself contains one element: the
|
||||
# <top> tag.
|
||||
assert len(soup.contents) == 1
|
||||
assert len(soup) == 1
|
||||
|
||||
# The <top> tag contains three elements: the text node "1", the
|
||||
# <b> tag, and the text node "3".
|
||||
assert len(soup.top) == 3
|
||||
assert len(soup.top.contents) == 3
|
||||
|
||||
def test_member_access_invokes_find(self):
|
||||
"""Accessing a Python member .foo invokes find('foo')"""
|
||||
soup = self.soup('<b><i></i></b>')
|
||||
assert soup.b == soup.find('b')
|
||||
assert soup.b.i == soup.find('b').find('i')
|
||||
assert soup.a == None
|
||||
|
||||
def test_deprecated_member_access(self):
|
||||
soup = self.soup('<b><i></i></b>')
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
tag = soup.bTag
|
||||
assert soup.b == tag
|
||||
assert '.bTag is deprecated, use .find("b") instead. If you really were looking for a tag called bTag, use .find("bTag")' == str(w[0].message)
|
||||
|
||||
def test_has_attr(self):
|
||||
"""has_attr() checks for the presence of an attribute.
|
||||
|
||||
Please note note: has_attr() is different from
|
||||
__in__. has_attr() checks the tag's attributes and __in__
|
||||
checks the tag's chidlren.
|
||||
"""
|
||||
soup = self.soup("<foo attr='bar'>")
|
||||
assert soup.foo.has_attr('attr')
|
||||
assert not soup.foo.has_attr('attr2')
|
||||
|
||||
def test_attributes_come_out_in_alphabetical_order(self):
|
||||
markup = '<b a="1" z="5" m="3" f="2" y="4"></b>'
|
||||
self.assertSoupEquals(markup, '<b a="1" f="2" m="3" y="4" z="5"></b>')
|
||||
|
||||
def test_string(self):
|
||||
# A Tag that contains only a text node makes that node
|
||||
# available as .string.
|
||||
soup = self.soup("<b>foo</b>")
|
||||
assert soup.b.string == 'foo'
|
||||
|
||||
def test_empty_tag_has_no_string(self):
|
||||
# A Tag with no children has no .stirng.
|
||||
soup = self.soup("<b></b>")
|
||||
assert soup.b.string == None
|
||||
|
||||
def test_tag_with_multiple_children_has_no_string(self):
|
||||
# A Tag with no children has no .string.
|
||||
soup = self.soup("<a>foo<b></b><b></b></b>")
|
||||
assert soup.b.string == None
|
||||
|
||||
soup = self.soup("<a>foo<b></b>bar</b>")
|
||||
assert soup.b.string == None
|
||||
|
||||
# Even if all the children are strings, due to trickery,
|
||||
# it won't work--but this would be a good optimization.
|
||||
soup = self.soup("<a>foo</b>")
|
||||
soup.a.insert(1, "bar")
|
||||
assert soup.a.string == None
|
||||
|
||||
def test_tag_with_recursive_string_has_string(self):
|
||||
# A Tag with a single child which has a .string inherits that
|
||||
# .string.
|
||||
soup = self.soup("<a><b>foo</b></a>")
|
||||
assert soup.a.string == "foo"
|
||||
assert soup.string == "foo"
|
||||
|
||||
def test_lack_of_string(self):
|
||||
"""Only a Tag containing a single text node has a .string."""
|
||||
soup = self.soup("<b>f<i>e</i>o</b>")
|
||||
assert soup.b.string is None
|
||||
|
||||
soup = self.soup("<b></b>")
|
||||
assert soup.b.string is None
|
||||
|
||||
def test_all_text(self):
|
||||
"""Tag.text and Tag.get_text(sep=u"") -> all child text, concatenated"""
|
||||
soup = self.soup("<a>a<b>r</b> <r> t </r></a>")
|
||||
assert soup.a.text == "ar t "
|
||||
assert soup.a.get_text(strip=True) == "art"
|
||||
assert soup.a.get_text(",") == "a,r, , t "
|
||||
assert soup.a.get_text(",", strip=True) == "a,r,t"
|
||||
|
||||
def test_get_text_ignores_special_string_containers(self):
|
||||
soup = self.soup("foo<!--IGNORE-->bar")
|
||||
assert soup.get_text() == "foobar"
|
||||
|
||||
assert soup.get_text(types=(NavigableString, Comment)) == "fooIGNOREbar"
|
||||
assert soup.get_text(types=None) == "fooIGNOREbar"
|
||||
|
||||
soup = self.soup("foo<style>CSS</style><script>Javascript</script>bar")
|
||||
assert soup.get_text() == "foobar"
|
||||
|
||||
def test_all_strings_ignores_special_string_containers(self):
|
||||
soup = self.soup("foo<!--IGNORE-->bar")
|
||||
assert ['foo', 'bar'] == list(soup.strings)
|
||||
|
||||
soup = self.soup("foo<style>CSS</style><script>Javascript</script>bar")
|
||||
assert ['foo', 'bar'] == list(soup.strings)
|
||||
|
||||
def test_string_methods_inside_special_string_container_tags(self):
|
||||
# Strings inside tags like <script> are generally ignored by
|
||||
# methods like get_text, because they're not what humans
|
||||
# consider 'text'. But if you call get_text on the <script>
|
||||
# tag itself, those strings _are_ considered to be 'text',
|
||||
# because there's nothing else you might be looking for.
|
||||
|
||||
style = self.soup("<div>a<style>Some CSS</style></div>")
|
||||
template = self.soup("<div>a<template><p>Templated <b>text</b>.</p><!--With a comment.--></template></div>")
|
||||
script = self.soup("<div>a<script><!--a comment-->Some text</script></div>")
|
||||
|
||||
assert style.div.get_text() == "a"
|
||||
assert list(style.div.strings) == ["a"]
|
||||
assert style.div.style.get_text() == "Some CSS"
|
||||
assert list(style.div.style.strings) == ['Some CSS']
|
||||
|
||||
# The comment is not picked up here. That's because it was
|
||||
# parsed into a Comment object, which is not considered
|
||||
# interesting by template.strings.
|
||||
assert template.div.get_text() == "a"
|
||||
assert list(template.div.strings) == ["a"]
|
||||
assert template.div.template.get_text() == "Templated text."
|
||||
assert list(template.div.template.strings) == ["Templated ", "text", "."]
|
||||
|
||||
# The comment is included here, because it didn't get parsed
|
||||
# into a Comment object--it's part of the Script string.
|
||||
assert script.div.get_text() == "a"
|
||||
assert list(script.div.strings) == ["a"]
|
||||
assert script.div.script.get_text() == "<!--a comment-->Some text"
|
||||
assert list(script.div.script.strings) == ['<!--a comment-->Some text']
|
||||
|
||||
|
||||
class TestMultiValuedAttributes(SoupTest):
|
||||
"""Test the behavior of multi-valued attributes like 'class'.
|
||||
|
||||
The values of such attributes are always presented as lists.
|
||||
"""
|
||||
|
||||
def test_single_value_becomes_list(self):
|
||||
soup = self.soup("<a class='foo'>")
|
||||
assert ["foo"] ==soup.a['class']
|
||||
|
||||
def test_multiple_values_becomes_list(self):
|
||||
soup = self.soup("<a class='foo bar'>")
|
||||
assert ["foo", "bar"] == soup.a['class']
|
||||
|
||||
def test_multiple_values_separated_by_weird_whitespace(self):
|
||||
soup = self.soup("<a class='foo\tbar\nbaz'>")
|
||||
assert ["foo", "bar", "baz"] ==soup.a['class']
|
||||
|
||||
def test_attributes_joined_into_string_on_output(self):
|
||||
soup = self.soup("<a class='foo\tbar'>")
|
||||
assert b'<a class="foo bar"></a>' == soup.a.encode()
|
||||
|
||||
def test_get_attribute_list(self):
|
||||
soup = self.soup("<a id='abc def'>")
|
||||
assert ['abc def'] == soup.a.get_attribute_list('id')
|
||||
|
||||
def test_accept_charset(self):
|
||||
soup = self.soup('<form accept-charset="ISO-8859-1 UTF-8">')
|
||||
assert ['ISO-8859-1', 'UTF-8'] == soup.form['accept-charset']
|
||||
|
||||
def test_cdata_attribute_applying_only_to_one_tag(self):
|
||||
data = '<a accept-charset="ISO-8859-1 UTF-8"></a>'
|
||||
soup = self.soup(data)
|
||||
# We saw in another test that accept-charset is a cdata-list
|
||||
# attribute for the <form> tag. But it's not a cdata-list
|
||||
# attribute for any other tag.
|
||||
assert 'ISO-8859-1 UTF-8' == soup.a['accept-charset']
|
||||
|
||||
def test_customization(self):
|
||||
# It's possible to change which attributes of which tags
|
||||
# are treated as multi-valued attributes.
|
||||
#
|
||||
# Here, 'id' is a multi-valued attribute and 'class' is not.
|
||||
#
|
||||
# TODO: This code is in the builder and should be tested there.
|
||||
soup = self.soup(
|
||||
'<a class="foo" id="bar">', multi_valued_attributes={ '*' : 'id' }
|
||||
)
|
||||
assert soup.a['class'] == 'foo'
|
||||
assert soup.a['id'] == ['bar']
|
||||
|
||||
def test_hidden_tag_is_invisible(self):
|
||||
# Setting .hidden on a tag makes it invisible in output, but
|
||||
# leaves its contents visible.
|
||||
#
|
||||
# This is not a documented or supported feature of Beautiful
|
||||
# Soup (e.g. NavigableString doesn't support .hidden even
|
||||
# though it could), but some people use it and it's not
|
||||
# hurting anything to verify that it keeps working.
|
||||
#
|
||||
soup = self.soup('<div id="1"><span id="2">a string</span></div>')
|
||||
soup.span.hidden = True
|
||||
assert '<div id="1">a string</div>' == str(soup.div)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,99 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: greenlet
|
||||
Version: 3.5.6
|
||||
Summary: Lightweight in-process concurrent programming
|
||||
Author-email: Alexey Borzenkov <snaury@gmail.com>
|
||||
Maintainer-email: Jason Madden <jason@seecoresoftware.com>
|
||||
License-Expression: MIT AND PSF-2.0
|
||||
Project-URL: Homepage, https://greenlet.readthedocs.io
|
||||
Project-URL: Documentation, https://greenlet.readthedocs.io
|
||||
Project-URL: Repository, https://github.com/python-greenlet/greenlet
|
||||
Project-URL: Issues, https://github.com/python-greenlet/greenlet/issues
|
||||
Project-URL: Changelog, https://greenlet.readthedocs.io/en/latest/changes.html
|
||||
Keywords: greenlet,coroutine,concurrency,threads,cooperative
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Natural Language :: English
|
||||
Classifier: Programming Language :: C
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Programming Language :: Python :: 3.13
|
||||
Classifier: Programming Language :: Python :: 3.14
|
||||
Classifier: Programming Language :: Python :: 3.15
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Requires-Python: >=3.10
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
License-File: LICENSE.PSF
|
||||
Provides-Extra: docs
|
||||
Requires-Dist: Sphinx; extra == "docs"
|
||||
Requires-Dist: furo; extra == "docs"
|
||||
Provides-Extra: test
|
||||
Requires-Dist: objgraph; extra == "test"
|
||||
Requires-Dist: psutil; extra == "test"
|
||||
Requires-Dist: setuptools; extra == "test"
|
||||
Dynamic: license-file
|
||||
|
||||
.. This file is included into docs/history.rst
|
||||
|
||||
|
||||
Greenlets are lightweight coroutines for in-process concurrent
|
||||
programming.
|
||||
|
||||
The "greenlet" package is a spin-off of `Stackless`_, a version of
|
||||
CPython that supports micro-threads called "tasklets". Tasklets run
|
||||
pseudo-concurrently (typically in a single or a few OS-level threads)
|
||||
and are synchronized with data exchanges on "channels".
|
||||
|
||||
A "greenlet", on the other hand, is a still more primitive notion of
|
||||
micro-thread with no implicit scheduling; coroutines, in other words.
|
||||
This is useful when you want to control exactly when your code runs.
|
||||
You can build custom scheduled micro-threads on top of greenlet;
|
||||
however, it seems that greenlets are useful on their own as a way to
|
||||
make advanced control flow structures. For example, we can recreate
|
||||
generators; the difference with Python's own generators is that our
|
||||
generators can call nested functions and the nested functions can
|
||||
yield values too. (Additionally, you don't need a "yield" keyword. See
|
||||
the example in `test_generator.py
|
||||
<https://github.com/python-greenlet/greenlet/blob/adca19bf1f287b3395896a8f41f3f4fd1797fdc7/src/greenlet/tests/test_generator.py#L1>`_).
|
||||
|
||||
Greenlets are provided as a C extension module for the regular unmodified
|
||||
interpreter.
|
||||
|
||||
.. _`Stackless`: http://www.stackless.com
|
||||
|
||||
|
||||
Who is using Greenlet?
|
||||
======================
|
||||
|
||||
There are several libraries that use Greenlet as a more flexible
|
||||
alternative to Python's built in coroutine support:
|
||||
|
||||
- `Concurrence`_
|
||||
- `Eventlet`_
|
||||
- `Gevent`_
|
||||
|
||||
.. _Concurrence: http://opensource.hyves.org/concurrence/
|
||||
.. _Eventlet: http://eventlet.net/
|
||||
.. _Gevent: http://www.gevent.org/
|
||||
|
||||
Getting Greenlet
|
||||
================
|
||||
|
||||
The easiest way to get Greenlet is to install it with pip::
|
||||
|
||||
pip install greenlet
|
||||
|
||||
|
||||
Source code archives and binary distributions are available on the
|
||||
python package index at https://pypi.org/project/greenlet
|
||||
|
||||
The source code repository is hosted on github:
|
||||
https://github.com/python-greenlet/greenlet
|
||||
|
||||
Documentation is available on readthedocs.org:
|
||||
https://greenlet.readthedocs.io
|
||||
@@ -0,0 +1,129 @@
|
||||
../../../include/site/python3.11/greenlet/greenlet.h,sha256=sz5pYRSQqedgOt2AMgxLZdTjO-qcr_JMvgiEJR9IAJ8,4755
|
||||
greenlet-3.5.6.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
greenlet-3.5.6.dist-info/METADATA,sha256=ubTLvB8xZeUPvEaWxDP-grihUf3mXu3ij7bsXvs-H6s,3782
|
||||
greenlet-3.5.6.dist-info/RECORD,,
|
||||
greenlet-3.5.6.dist-info/WHEEL,sha256=wlqGja50c3s8v3yxgtBRnvU69dZJ2qtgEGMZVt1jE9Y,152
|
||||
greenlet-3.5.6.dist-info/licenses/LICENSE,sha256=dpgx1uXfrywggC-sz_H6-0wgJd2PYlPfpH_K1Z1NCXk,1434
|
||||
greenlet-3.5.6.dist-info/licenses/LICENSE.PSF,sha256=5f88I8EQ5JTNfXNsEP2W1GJFe6_soxCEDbZScpjH1Gs,2424
|
||||
greenlet-3.5.6.dist-info/top_level.txt,sha256=YSnRsCRoO61JGlP57o8iKL6rdLWDWuiyKD8ekpWUsDc,9
|
||||
greenlet/CObjects.cpp,sha256=dwrhOTu_62B-A-zi9ssd89H6AZyq82Irddemn2qx9qM,3652
|
||||
greenlet/PyGreenlet.cpp,sha256=s5lYEoUmlngZzM3vJ16b-GB_F0kCy8FMSANF_Ujwtok,28229
|
||||
greenlet/PyGreenlet.hpp,sha256=2ZQlOxYNoy7QwD7mppFoOXe_At56NIsJ0eNsE_hoSsw,1463
|
||||
greenlet/PyGreenletUnswitchable.cpp,sha256=XxpqPaQLOXaEcWTTfKMS8R7Osa3yzp7B_QYbFzxc0ck,4156
|
||||
greenlet/PyModule.cpp,sha256=ZKT6KNgxqmLCFguciNwyCPL84pQQtTIcLXfbuDnSJn0,8794
|
||||
greenlet/TBrokenGreenlet.cpp,sha256=smN26uC7ahAbNYiS10rtWPjCeTG4jevM8siA2sjJiXg,1021
|
||||
greenlet/TExceptionState.cpp,sha256=U7Ctw9fBdNraS0d174MoQW7bN-ae209Ta0JuiKpcpVI,1359
|
||||
greenlet/TGreenlet.cpp,sha256=MBgCNwP0yRTFbKhOFAJdBOt-IAbw0VtY4rUxXU3yo7M,26390
|
||||
greenlet/TGreenlet.hpp,sha256=r5D8tQUsvenpglLBgHnQEI37lWoyzMkr3Vwij544hvc,30302
|
||||
greenlet/TGreenletGlobals.cpp,sha256=Amj0iOP_JUqfDfYeqtw_K4KlNcKR2cqDGuU6A6W8nJs,3930
|
||||
greenlet/TMainGreenlet.cpp,sha256=ghEHPdjAnc1eMcEo8ag00fZPR_JeM8aJby8l_IQewtc,3593
|
||||
greenlet/TPythonState.cpp,sha256=KTf50_vYdk3x7mrRDJ2Nzz6YkDWYbd-njKbsbYg4T2M,25879
|
||||
greenlet/TStackState.cpp,sha256=V444I8Jj9DhQz-9leVW_9dtiSRjaE1NMlgDG02Xxq-Y,7381
|
||||
greenlet/TThreadState.hpp,sha256=tGZwsV8DWv7cZ0dB8cUuBRMXYrdHFJniao8gdfoMYTI,23902
|
||||
greenlet/TThreadStateCreator.hpp,sha256=WIOSdT0Ey6KFqnM9qj3D95s9q7kUFsTSBgEQG3bbfJI,2767
|
||||
greenlet/TThreadStateDestroy.cpp,sha256=sUWjvJ4hT9nbJd6k9JLwjDqFcv2TdFaO_X9d41RbgoE,8178
|
||||
greenlet/TUserGreenlet.cpp,sha256=JEe8d6Zusm2TsbIWHsRv3bg7DRbTGvXT0bX3dX3Dyys,24485
|
||||
greenlet/__init__.py,sha256=AmL4wf6Jc9PA1vVZgm7pFc5ez3iPO-H8MegzjtJKOtI,1442
|
||||
greenlet/__pycache__/__init__.cpython-311.pyc,,
|
||||
greenlet/_greenlet.cpython-311-x86_64-linux-gnu.so,sha256=_U6Ot6kKpCLdonqqfVTkXWfn4lBQzVE-hJKmBTRi0vM,1414072
|
||||
greenlet/greenlet.cpp,sha256=Cy3WvYsQc9t6K3hWUYjxAymkPW-KGvEyybGVos4PAoI,11088
|
||||
greenlet/greenlet.h,sha256=sz5pYRSQqedgOt2AMgxLZdTjO-qcr_JMvgiEJR9IAJ8,4755
|
||||
greenlet/greenlet_allocator.hpp,sha256=n28rwj76RVSn7B5QDA00nL8OBjfFeiOM1QGrVrHhfsk,1835
|
||||
greenlet/greenlet_compiler_compat.hpp,sha256=nRxpLN9iNbnLVyFDeVmOwyeeNm6scQrOed1l7JQYMCM,4346
|
||||
greenlet/greenlet_cpython_compat.hpp,sha256=Mo2YqcvZsC7ZaWQTXOL7oyfpSjo-7nExMhus0KXx2TU,3325
|
||||
greenlet/greenlet_exceptions.hpp,sha256=06Bx81DtVaJTa6RtiMcV141b-XHv4ppEgVItkblcLWY,4503
|
||||
greenlet/greenlet_internal.hpp,sha256=YCqnF6tbiWDlInNiZ5AU9RG8iC3dQlGFr8lk_JMtY2Q,2768
|
||||
greenlet/greenlet_msvc_compat.hpp,sha256=3DsuiBeUi4SkZIxGJC4eSulFqWZ9ZhxZLls0Ie1ORCU,4913
|
||||
greenlet/greenlet_refs.hpp,sha256=OusuTGyHhcLEqGoqVw9VAsFvQ6MBvO_AgGpGKkcmPYA,38067
|
||||
greenlet/greenlet_slp_switch.hpp,sha256=T1Y-w01yBBljePiHgUaWCs3XZSdtHSrtLvvkMXFDUN4,3298
|
||||
greenlet/greenlet_thread_support.hpp,sha256=XUJ6ljWjf9OYyuOILiz8e_yHvT3fbaUiHdhiPNQUV4s,867
|
||||
greenlet/platform/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
greenlet/platform/__pycache__/__init__.cpython-311.pyc,,
|
||||
greenlet/platform/setup_switch_x64_masm.cmd,sha256=ZpClUJeU0ujEPSTWNSepP0W2f9XiYQKA8QKSoVou8EU,143
|
||||
greenlet/platform/switch_aarch64_gcc.h,sha256=GKC0yWNXnbK2X--X6aguRCMj2Tg7hDU1Zkl3RljDvC8,4307
|
||||
greenlet/platform/switch_alpha_unix.h,sha256=Z-SvF8JQV3oxWT8JRbL9RFu4gRFxPdJ7cviM8YayMmw,671
|
||||
greenlet/platform/switch_amd64_unix.h,sha256=EcSFCBlodEBhqhKjcJqY_5Dn_jn7pKpkJlOvp7gFXLI,2748
|
||||
greenlet/platform/switch_arm32_gcc.h,sha256=Z3KkHszdgq6uU4YN3BxvKMG2AdDnovwCCNrqGWZ1Lyo,2479
|
||||
greenlet/platform/switch_arm32_ios.h,sha256=mm5_R9aXB92hyxzFRwB71M60H6AlvHjrpTrc72Pz3l8,1892
|
||||
greenlet/platform/switch_arm64_masm.asm,sha256=4kpTtfy7rfcr8j1CpJLAK21EtZpGDAJXWRU68HEy5A8,1245
|
||||
greenlet/platform/switch_arm64_masm.obj,sha256=DmLnIB_icoEHAz1naue_pJPTZgR9ElM7-Nmztr-o9_U,746
|
||||
greenlet/platform/switch_arm64_msvc.h,sha256=RqK5MHLmXI3Q-FQ7tm32KWnbDNZKnkJdq8CR89cz640,398
|
||||
greenlet/platform/switch_csky_gcc.h,sha256=kDikyiPpewP71KoBZQO_MukDTXTXBiC7x-hF0_2DL0w,1331
|
||||
greenlet/platform/switch_loongarch64_linux.h,sha256=7M-Dhc4Q8tRbJCJhalDLwU6S9Mx8MjmN1RbTDgIvQTM,779
|
||||
greenlet/platform/switch_m68k_gcc.h,sha256=VSa6NpZhvyyvF-Q58CTIWSpEDo4FKygOyTz00whctlw,928
|
||||
greenlet/platform/switch_mips_unix.h,sha256=DsbNLh3Nde3WhnK5dwNaVu9r60iAWoCyEemxwBBOHBI,1462
|
||||
greenlet/platform/switch_ppc64_aix.h,sha256=_BL0iyRr3ZA5iPlr3uk9SJ5sNRWGYLrXcZ5z-CE9anE,3860
|
||||
greenlet/platform/switch_ppc64_linux.h,sha256=0rriT5XyxPb0GqsSSn_bP9iQsnjsPbBmu0yqo5goSyQ,3815
|
||||
greenlet/platform/switch_ppc_aix.h,sha256=pHA4slEjUFP3J3SYm1TAlNPhgb2G_PAtax5cO8BEe1A,2941
|
||||
greenlet/platform/switch_ppc_linux.h,sha256=YwrlKUzxlXuiKMQqr6MFAV1bPzWnmvk6X1AqJZEpOWU,2759
|
||||
greenlet/platform/switch_ppc_macosx.h,sha256=Z6KN_ud0n6nC3ltJrNz2qtvER6vnRAVRNH9mdIDpMxY,2624
|
||||
greenlet/platform/switch_ppc_unix.h,sha256=-ZG7MSSPEA5N4qO9PQChtyEJ-Fm6qInhyZm_ZBHTtMg,2652
|
||||
greenlet/platform/switch_riscv_unix.h,sha256=606V6ACDf79Fz_WGItnkgbjIJ0pGg_sHmPyDxQYKK58,949
|
||||
greenlet/platform/switch_s390_unix.h,sha256=RRlGu957ybmq95qNNY4Qw1mcaoT3eBnW5KbVwu48KX8,2763
|
||||
greenlet/platform/switch_sh_gcc.h,sha256=mcRJBTu-2UBf4kZtX601qofwuDuy-Y-hnxJtrcaB7do,901
|
||||
greenlet/platform/switch_sparc_sun_gcc.h,sha256=xZish9GsMHBienUbUMsX1-ZZ-as7hs36sVhYIE3ew8Y,2797
|
||||
greenlet/platform/switch_x32_unix.h,sha256=nM98PKtzTWc1lcM7TRMUZJzskVdR1C69U1UqZRWX0GE,1509
|
||||
greenlet/platform/switch_x64_masm.asm,sha256=nu6n2sWyXuXfpPx40d9YmLfHXUc1sHgeTvX1kUzuvEM,1841
|
||||
greenlet/platform/switch_x64_masm.obj,sha256=GNtTNxYdo7idFUYsQv-mrXWgyT5EJ93-9q90lN6svtQ,1078
|
||||
greenlet/platform/switch_x64_msvc.h,sha256=LIeasyKo_vHzspdMzMHbosRhrBfKI4BkQOh4qcTHyJw,1805
|
||||
greenlet/platform/switch_x86_msvc.h,sha256=TtGOwinbFfnn6clxMNkCz8i6OmgB6kVRrShoF5iT9to,12838
|
||||
greenlet/platform/switch_x86_unix.h,sha256=VplW9H0FF0cZHw1DhJdIUs5q6YLS4cwb2nYwjF83R1s,3059
|
||||
greenlet/slp_platformselect.h,sha256=hTb3GFdcPUYJTuu1MY93js7MZEax1_e5E-gflpi0RzI,3959
|
||||
greenlet/tests/__init__.py,sha256=EtTtQfpRDde0MhsdAM5Cm7LYIfS_HKUIFwquiH4Q7ac,9736
|
||||
greenlet/tests/__pycache__/__init__.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_c_stack_refs_suspended_gc.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_clearing_run_switches.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_cpp_exception.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_initialstub_already_started.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_issue_515_freethread_gc.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_slp_switch.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_switch_critical_section.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_switch_three_greenlets.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_switch_three_greenlets2.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/fail_switch_two_greenlets.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/leakcheck.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_contextvars.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_cpp.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_extension_interface.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_gc.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_generator.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_generator_nested.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_greenlet.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_greenlet_trash.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_interpreter_shutdown.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_leaks.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_stack_saved.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_throw.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_tracing.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_version.cpython-311.pyc,,
|
||||
greenlet/tests/__pycache__/test_weakref.cpython-311.pyc,,
|
||||
greenlet/tests/_test_extension.c,sha256=kIvRhipcQBjm4Di_iH-bCgi05IzPG6wW0xOQTUYqrqI,7301
|
||||
greenlet/tests/_test_extension.cpython-311-x86_64-linux-gnu.so,sha256=gCEVWbyHVMxvZTC27F2xCugcOZN4WaXntZfPLaW_oik,17328
|
||||
greenlet/tests/_test_extension_cpp.cpp,sha256=P2qtldzeOLTVdf7KS6il33KsO9mg2kUO0nmYbXvVFaE,6713
|
||||
greenlet/tests/_test_extension_cpp.cpython-311-x86_64-linux-gnu.so,sha256=j0vhJ3Q1KraFarwZJsGRccNL1Vr_XzNFu2ecUTOBHtg,58200
|
||||
greenlet/tests/fail_c_stack_refs_suspended_gc.py,sha256=9KMpZ2aTJcsJ2dyCQ04sbC3UxQ0S72-M1Zv9stmGOsQ,3382
|
||||
greenlet/tests/fail_clearing_run_switches.py,sha256=o433oA_nUCtOPaMEGc8VEhZIKa71imVHXFw7TsXaP8M,1263
|
||||
greenlet/tests/fail_cpp_exception.py,sha256=o_ZbipWikok8Bjc-vjiQvcb5FHh2nVW-McGKMLcMzh0,985
|
||||
greenlet/tests/fail_initialstub_already_started.py,sha256=txENn5IyzGx2p-XR1XB7qXmC8JX_4mKDEA8kYBXUQKc,1961
|
||||
greenlet/tests/fail_issue_515_freethread_gc.py,sha256=5kWxGBzVz47HbaGgffFMRW_EdiuW42SpHutyyKDawFM,4000
|
||||
greenlet/tests/fail_slp_switch.py,sha256=rJBZcZfTWR3e2ERQtPAud6YKShiDsP84PmwOJbp4ey0,524
|
||||
greenlet/tests/fail_switch_critical_section.py,sha256=eETv7iIf0nmNm-Y5d0FqqqO3X2EH5rM_Ua80lskjCzs,1481
|
||||
greenlet/tests/fail_switch_three_greenlets.py,sha256=zSitV7rkNnaoHYVzAGGLnxz-yPtohXJJzaE8ehFDQ0M,956
|
||||
greenlet/tests/fail_switch_three_greenlets2.py,sha256=FPJensn2EJxoropl03JSTVP3kgP33k04h6aDWWozrOk,1285
|
||||
greenlet/tests/fail_switch_two_greenlets.py,sha256=1CaI8s3504VbbF1vj1uBYuy-zxBHVzHPIAd1LIc8ONg,817
|
||||
greenlet/tests/leakcheck.py,sha256=tz4P2RIPI6CFwEFq8WSywE_Sg_4M0DQVYYJX8Bsj6T0,13613
|
||||
greenlet/tests/test_contextvars.py,sha256=bMWvdF26uxawPM2hSvuPscdvRAPwQE3gAM6qzqEtZgY,9564
|
||||
greenlet/tests/test_cpp.py,sha256=Q4sIw4Zrennpq0QMaFqV8i5bVUjh68Q67wjJGv5pT5A,3164
|
||||
greenlet/tests/test_extension_interface.py,sha256=U-6pKuoQrcJEIb4xtBP0uLh-RixwCnh-XryrwAXfAoU,4820
|
||||
greenlet/tests/test_gc.py,sha256=b5lA4uhPyfO4Y95Eb6LKqiIv2X0TqDPGoCWNXVnh_-U,5995
|
||||
greenlet/tests/test_generator.py,sha256=tONXiTf98VGm347o1b-810daPiwdla5cbpFg6QI1R1g,1240
|
||||
greenlet/tests/test_generator_nested.py,sha256=7v4HOYrf1XZP39dk5IUMubdZ8yc3ynwZcqj9GUJyMSA,3718
|
||||
greenlet/tests/test_greenlet.py,sha256=2WEf2w2Yr2CvXMy0JYywVkTlaGHeC0vfY6d_tHfUTZk,53325
|
||||
greenlet/tests/test_greenlet_trash.py,sha256=M94zmemBACFQ1G9lfuqr2dCZMZ1YAQCagVuIn3O_TuI,8369
|
||||
greenlet/tests/test_interpreter_shutdown.py,sha256=FULvqcekwjdAn3ch61gCyj2rTsvxw0RO4FWSMhsJNzk,33065
|
||||
greenlet/tests/test_leaks.py,sha256=4L_xTmWRKa3fYC9bxZOyRWCbtz1oEMhjpqyu-PT5rJQ,20486
|
||||
greenlet/tests/test_stack_saved.py,sha256=eyzqNY2VCGuGlxhT_In6TvZ6Okb0AXFZVyBEnK1jDwA,446
|
||||
greenlet/tests/test_throw.py,sha256=u2TQ_WvvCd6N6JdXWIxVEcXkKu5fepDlz9dktYdmtng,3712
|
||||
greenlet/tests/test_tracing.py,sha256=NwU4Z4z7Yt2ekv7GB8-dVM3aJ3ZMx1utqbpv_-KU5B8,8553
|
||||
greenlet/tests/test_version.py,sha256=8lk49x1wav1RWGpQk5MA8i_ZP-M0OhpHXUftuqVN72c,1528
|
||||
greenlet/tests/test_weakref.py,sha256=F8M23btEF87bIbpptLNBORosbQqNZGiYeKMqYjWrsak,883
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (84.0.0)
|
||||
Root-Is-Purelib: false
|
||||
Tag: cp311-cp311-manylinux_2_26_x86_64
|
||||
Tag: cp311-cp311-manylinux_2_24_x86_64
|
||||
Tag: cp311-cp311-manylinux_2_28_x86_64
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
The following files are derived from Stackless Python and are subject to the
|
||||
same license as Stackless Python:
|
||||
|
||||
src/greenlet/slp_platformselect.h
|
||||
files in src/greenlet/platform/ directory
|
||||
|
||||
See LICENSE.PSF and http://www.stackless.com/ for details.
|
||||
|
||||
Unless otherwise noted, the files in greenlet have been released under the
|
||||
following MIT license:
|
||||
|
||||
Copyright (c) Armin Rigo, Christian Tismer and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,47 @@
|
||||
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||
--------------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||
otherwise using this software ("Python") in source or binary form and
|
||||
its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||
distribute, and otherwise use Python alone or in any derivative version,
|
||||
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||
2011 Python Software Foundation; All Rights Reserved" are retained in Python
|
||||
alone or in any derivative version prepared by Licensee.
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python.
|
||||
|
||||
4. PSF is making Python available to Licensee on an "AS IS"
|
||||
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. Nothing in this License Agreement shall be deemed to create any
|
||||
relationship of agency, partnership, or joint venture between PSF and
|
||||
Licensee. This License Agreement does not grant permission to use PSF
|
||||
trademarks or trade name in a trademark sense to endorse or promote
|
||||
products or services of Licensee, or any third party.
|
||||
|
||||
8. By copying, installing or otherwise using Python, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user