Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acff263dba | |||
| 04b78497b9 | |||
| 7455d7e426 | |||
| 02ea794015 | |||
| a0a06dfd8c | |||
| 3616e5799d | |||
| 52bb1f53cc | |||
| 76b734b0a9 | |||
| cad1390534 | |||
| 022d348052 | |||
| 5e0582d499 | |||
| b1bbf64287 | |||
| 7db14ee411 | |||
| d9af819d2e | |||
| a994dc5f53 | |||
| 1cdd0bcab1 | |||
| 2324725c6f | |||
| 8800d4b2b1 | |||
| 79ef7c66af |
@@ -0,0 +1,30 @@
|
||||
# 应用
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql+asyncpg://gx-gp-notify:password@10.10.10.14:5432/gx-gp-notify
|
||||
|
||||
# 爬虫
|
||||
CRAWLER_BASE_URL=https://zfcg.gxzf.gov.cn
|
||||
CRAWLER_KEYWORDS=["大化"]
|
||||
CRAWLER_MAX_PAGES=10
|
||||
CRAWLER_TIMEOUT=30
|
||||
|
||||
# 企业微信
|
||||
WECHAT_ENABLED=true
|
||||
WECHAT_CORP_ID=ww69e8e44636f47780
|
||||
WECHAT_AGENT_ID=1000007
|
||||
WECHAT_SECRET=
|
||||
WECHAT_TOKEN=
|
||||
WECHAT_ENCODING_AES_KEY=
|
||||
WECHAT_PORT=18001
|
||||
WECHAT_HOST=0.0.0.0
|
||||
|
||||
# 定时任务
|
||||
SCHEDULER_ENABLED=true
|
||||
SCHEDULER_CRON=0 8,14,18 * * *
|
||||
|
||||
# Markdown
|
||||
MARKDOWN_ENABLED=true
|
||||
MARKDOWN_OUTPUT_FILE=onu.md
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
config.yaml
|
||||
gx_gp_monitor/config/config.yaml
|
||||
onu.md
|
||||
Binary file not shown.
+149
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,34 @@
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from app.models.announcement import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
from app.config import settings
|
||||
url = settings.database_url
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
from app.config import settings
|
||||
connectable = engine_from_config(
|
||||
{"sqlalchemy.url": settings.database_url},
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,194 @@
|
||||
"""create_announcements_table
|
||||
|
||||
Revision ID: 567cd83d63c2
|
||||
Revises:
|
||||
Create Date: 2026-05-09 13:33:26.593143
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '567cd83d63c2'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('auto_announcements')
|
||||
op.drop_table('announcement_sources')
|
||||
op.drop_index(op.f('idx_dahuagov_content_hash'), table_name='dahuagov_announcements')
|
||||
op.drop_index(op.f('idx_dahuagov_created_at'), table_name='dahuagov_announcements')
|
||||
op.drop_index(op.f('idx_dahuagov_publish_date'), table_name='dahuagov_announcements')
|
||||
op.drop_table('dahuagov_announcements')
|
||||
op.drop_index(op.f('idx_crawl_results_crawled_at'), table_name='crawl_results')
|
||||
op.drop_table('crawl_results')
|
||||
op.drop_table('manual_announcements')
|
||||
op.add_column('announcements', sa.Column('is_sent', sa.Boolean(), nullable=False))
|
||||
op.alter_column('announcements', 'purchase_name',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
nullable=False)
|
||||
op.alter_column('announcements', 'content_url',
|
||||
existing_type=sa.TEXT(),
|
||||
nullable=False)
|
||||
op.alter_column('announcements', 'content_hash',
|
||||
existing_type=sa.VARCHAR(length=32),
|
||||
type_=sa.String(length=64),
|
||||
nullable=False)
|
||||
op.alter_column('announcements', 'crawl_mode',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'auto'::character varying"))
|
||||
op.alter_column('announcements', 'is_new',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('true'))
|
||||
op.alter_column('announcements', 'keyword_matched',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('announcements', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.alter_column('announcements', 'updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.drop_index(op.f('idx_announcements_content_hash'), table_name='announcements')
|
||||
op.drop_index(op.f('idx_announcements_created_at'), table_name='announcements')
|
||||
op.drop_index(op.f('idx_announcements_publish_date'), table_name='announcements')
|
||||
op.drop_index(op.f('idx_announcements_source_code'), table_name='announcements')
|
||||
op.drop_column('announcements', 'date_filtered')
|
||||
op.drop_column('announcements', 'is_today')
|
||||
op.drop_column('announcements', 'crawled_at')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('announcements', sa.Column('crawled_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))
|
||||
op.add_column('announcements', sa.Column('is_today', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True))
|
||||
op.add_column('announcements', sa.Column('date_filtered', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True))
|
||||
op.create_index(op.f('idx_announcements_source_code'), 'announcements', ['source_code'], unique=False)
|
||||
op.create_index(op.f('idx_announcements_publish_date'), 'announcements', [sa.literal_column('publish_date DESC')], unique=False)
|
||||
op.create_index(op.f('idx_announcements_created_at'), 'announcements', [sa.literal_column('created_at DESC')], unique=False)
|
||||
op.create_index(op.f('idx_announcements_content_hash'), 'announcements', ['content_hash'], unique=False)
|
||||
op.alter_column('announcements', 'updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.alter_column('announcements', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.alter_column('announcements', 'keyword_matched',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('announcements', 'is_new',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('true'))
|
||||
op.alter_column('announcements', 'crawl_mode',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'auto'::character varying"))
|
||||
op.alter_column('announcements', 'content_hash',
|
||||
existing_type=sa.String(length=64),
|
||||
type_=sa.VARCHAR(length=32),
|
||||
nullable=True)
|
||||
op.alter_column('announcements', 'content_url',
|
||||
existing_type=sa.TEXT(),
|
||||
nullable=True)
|
||||
op.alter_column('announcements', 'purchase_name',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
nullable=True)
|
||||
op.drop_column('announcements', 'is_sent')
|
||||
op.create_table('manual_announcements',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('publish_date', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.Column('purchase_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('content_url', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('source_code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('source_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('announcement_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('crawled_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('content_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
|
||||
sa.Column('keyword_matched', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.Column('date_filtered', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('is_new', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('is_today', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('manual_announcements_pkey'))
|
||||
)
|
||||
op.create_table('crawl_results',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('source_code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('total_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('new_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('crawled_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('duration', sa.DOUBLE_PRECISION(precision=53), server_default=sa.text('0.0'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['source_code'], ['announcement_sources.code'], name=op.f('crawl_results_source_code_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('crawl_results_pkey'))
|
||||
)
|
||||
op.create_index(op.f('idx_crawl_results_crawled_at'), 'crawl_results', [sa.literal_column('crawled_at DESC')], unique=False)
|
||||
op.create_table('dahuagov_announcements',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('publish_date', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.Column('purchase_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('content_url', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('source_code', sa.VARCHAR(length=50), server_default=sa.text("'dahuagov'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.Column('source_name', sa.VARCHAR(length=100), server_default=sa.text("'大化县政府网采购公告'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.Column('announcement_type', sa.VARCHAR(length=50), server_default=sa.text("'purchase'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.Column('crawled_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('content_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
|
||||
sa.Column('is_new', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('dahuagov_announcements_pkey')),
|
||||
sa.UniqueConstraint('content_hash', name=op.f('dahuagov_announcements_content_hash_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_index(op.f('idx_dahuagov_publish_date'), 'dahuagov_announcements', [sa.literal_column('publish_date DESC')], unique=False)
|
||||
op.create_index(op.f('idx_dahuagov_created_at'), 'dahuagov_announcements', [sa.literal_column('created_at DESC')], unique=False)
|
||||
op.create_index(op.f('idx_dahuagov_content_hash'), 'dahuagov_announcements', ['content_hash'], unique=False)
|
||||
op.create_table('announcement_sources',
|
||||
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('category_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('code', name=op.f('announcement_sources_pkey'))
|
||||
)
|
||||
op.create_table('auto_announcements',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('publish_date', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.Column('purchase_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('content_url', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('source_code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('source_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('announcement_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('crawled_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('content_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
|
||||
sa.Column('keyword_matched', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.Column('date_filtered', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('is_new', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('is_today', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('auto_announcements_pkey')),
|
||||
sa.UniqueConstraint('content_hash', name=op.f('auto_announcements_content_hash_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,108 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.models.announcement import Announcement
|
||||
from app.models.schemas import AnnouncementListResponse, AnnouncementResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/announcements", response_model=AnnouncementListResponse)
|
||||
async def list_announcements(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
source_code: str | None = None,
|
||||
keyword: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
crawl_mode: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
if source_code:
|
||||
conditions.append(Announcement.source_code == source_code)
|
||||
if crawl_mode:
|
||||
conditions.append(Announcement.crawl_mode == crawl_mode)
|
||||
if start_date:
|
||||
conditions.append(Announcement.publish_date >= start_date)
|
||||
if end_date:
|
||||
conditions.append(Announcement.publish_date <= end_date)
|
||||
if keyword:
|
||||
conditions.append(
|
||||
Announcement.title.ilike(f"%{keyword}%")
|
||||
)
|
||||
|
||||
base_query = select(Announcement)
|
||||
if conditions:
|
||||
base_query = base_query.where(*conditions)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
items_query = base_query.order_by(Announcement.publish_date.desc()) \
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
items_result = await db.execute(items_query)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
return AnnouncementListResponse(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
items=[AnnouncementResponse.model_validate(item) for item in items],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/announcements/{announcement_id}", response_model=AnnouncementResponse)
|
||||
async def get_announcement(announcement_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Announcement).where(Announcement.id == announcement_id)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="公告不存在")
|
||||
return AnnouncementResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.get("/announcements/today", response_model=AnnouncementListResponse)
|
||||
async def get_today_announcements(db: AsyncSession = Depends(get_db)):
|
||||
today = datetime.now().date()
|
||||
result = await db.execute(
|
||||
select(Announcement).where(
|
||||
func.date(Announcement.publish_date) == today
|
||||
).order_by(Announcement.publish_date.desc())
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return AnnouncementListResponse(
|
||||
total=len(items), page=1, page_size=len(items),
|
||||
items=[AnnouncementResponse.model_validate(item) for item in items],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/announcements/stats")
|
||||
async def get_stats(db: AsyncSession = Depends(get_db)):
|
||||
total = await db.execute(select(func.count()).select_from(Announcement))
|
||||
today_count = await db.execute(
|
||||
select(func.count()).where(
|
||||
func.date(Announcement.publish_date) == func.current_date()
|
||||
).select_from(Announcement)
|
||||
)
|
||||
new_count = await db.execute(
|
||||
select(func.count()).where(Announcement.is_new == True) # noqa: E712
|
||||
.select_from(Announcement)
|
||||
)
|
||||
unsent = await db.execute(
|
||||
select(func.count()).where(
|
||||
Announcement.is_sent == False, Announcement.is_new == True # noqa: E712
|
||||
).select_from(Announcement)
|
||||
)
|
||||
return {
|
||||
"total": total.scalar() or 0,
|
||||
"today": today_count.scalar() or 0,
|
||||
"new": new_count.scalar() or 0,
|
||||
"unsent": unsent.scalar() or 0,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import get_crawl_service
|
||||
from app.models.schemas import CrawlTriggerRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/crawl/trigger")
|
||||
async def trigger_crawl(request: CrawlTriggerRequest):
|
||||
service = get_crawl_service()
|
||||
names = service.get_spider_names()
|
||||
|
||||
all_results = []
|
||||
for name in names:
|
||||
results = await service.run_spider(name)
|
||||
all_results.extend(results)
|
||||
|
||||
return {
|
||||
"spiders_run": names,
|
||||
"total_announcements": sum(r.total_count for r in all_results),
|
||||
"errors": [r.error_message for r in all_results if not r.success],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crawl/status")
|
||||
async def crawl_status():
|
||||
service = get_crawl_service()
|
||||
return {
|
||||
"spiders": service.get_spider_names(),
|
||||
"running": False,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crawl/sources")
|
||||
async def crawl_sources():
|
||||
import json
|
||||
|
||||
from app.config import settings
|
||||
sources = json.loads(settings.announcement_sources)
|
||||
return {
|
||||
"sources": [
|
||||
{"code": code, "name": info["name"], "type": info["type"]}
|
||||
for code, info in sources.items()
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crawler.dahuagov_spider import DahuagovSpider
|
||||
from app.crawler.gxgp_spider import GXGPSpider
|
||||
from app.services.crawl_service import CrawlService
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
from app.main import async_session # 延迟导入避免循环引用
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
_crawl_service: CrawlService | None = None
|
||||
|
||||
|
||||
def get_crawl_service() -> CrawlService:
|
||||
global _crawl_service
|
||||
if _crawl_service is None:
|
||||
_crawl_service = CrawlService()
|
||||
_crawl_service.register(GXGPSpider())
|
||||
_crawl_service.register(DahuagovSpider())
|
||||
return _crawl_service
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import announcements, crawl, wechat
|
||||
from app.api import scheduler as scheduler_module
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(announcements.router, tags=["announcements"])
|
||||
api_router.include_router(crawl.router, tags=["crawl"])
|
||||
api_router.include_router(wechat.router, tags=["wechat"])
|
||||
api_router.include_router(scheduler_module.router)
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models.schemas import JobResponse
|
||||
from app.scheduler.jobs import scheduler
|
||||
|
||||
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def list_jobs():
|
||||
jobs = []
|
||||
for job in scheduler.get_jobs():
|
||||
jobs.append(JobResponse(
|
||||
id=job.id,
|
||||
name=job.name,
|
||||
next_run_time=str(job.next_run_time) if job.next_run_time else None,
|
||||
))
|
||||
return {"jobs": jobs}
|
||||
|
||||
|
||||
@router.post("/pause/{job_id}")
|
||||
async def pause_job(job_id: str):
|
||||
scheduler.pause_job(job_id)
|
||||
return {"status": "paused", "job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/resume/{job_id}")
|
||||
async def resume_job(job_id: str):
|
||||
scheduler.resume_job(job_id)
|
||||
return {"status": "resumed", "job_id": job_id}
|
||||
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
from app.wechat.handler import WeChatMessageHandler
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_handler: WeChatMessageHandler | None = None
|
||||
|
||||
|
||||
def get_handler() -> WeChatMessageHandler:
|
||||
global _handler
|
||||
if _handler is None:
|
||||
_handler = WeChatMessageHandler()
|
||||
return _handler
|
||||
|
||||
|
||||
@router.get("/wechat/callback")
|
||||
async def wechat_verify(request: Request):
|
||||
handler = get_handler()
|
||||
params = request.query_params
|
||||
echostr = handler.verify_url(
|
||||
params.get("msg_signature", ""),
|
||||
params.get("timestamp", ""),
|
||||
params.get("nonce", ""),
|
||||
params.get("echostr", ""),
|
||||
)
|
||||
if echostr:
|
||||
return Response(content=echostr, media_type="text/plain")
|
||||
return Response(content="verification failed", status_code=403)
|
||||
|
||||
|
||||
@router.post("/wechat/callback")
|
||||
async def wechat_callback(request: Request):
|
||||
handler = get_handler()
|
||||
params = request.query_params
|
||||
post_data = await request.body()
|
||||
post_text = post_data.decode("utf-8")
|
||||
|
||||
xml_tree = handler.decrypt_message(
|
||||
post_text,
|
||||
params.get("msg_signature", ""),
|
||||
params.get("timestamp", ""),
|
||||
params.get("nonce", ""),
|
||||
)
|
||||
if xml_tree is None:
|
||||
return Response(content="decrypt failed", status_code=403)
|
||||
|
||||
msg_type = xml_tree.find("MsgType")
|
||||
msg_type = msg_type.text if msg_type is not None else "unknown"
|
||||
|
||||
if msg_type == "event":
|
||||
event = xml_tree.find("Event")
|
||||
event_key = xml_tree.find("EventKey")
|
||||
from_user = xml_tree.find("FromUserName")
|
||||
handler.handle_event(
|
||||
event.text if event is not None else "",
|
||||
event_key.text if event_key is not None else None,
|
||||
from_user.text if from_user is not None else "",
|
||||
)
|
||||
elif msg_type == "text":
|
||||
content = xml_tree.find("Content")
|
||||
from_user = xml_tree.find("FromUserName")
|
||||
handler.handle_text(
|
||||
content.text if content is not None else "",
|
||||
from_user.text if from_user is not None else "",
|
||||
)
|
||||
|
||||
return Response(content="success")
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
# 应用
|
||||
debug: bool = False
|
||||
log_level: str = "INFO"
|
||||
|
||||
# 数据库
|
||||
database_url: str = "postgresql+asyncpg://gx-gp-notify:password@localhost:5432/gx-gp-notify"
|
||||
|
||||
# 爬虫
|
||||
crawler_base_url: str = "https://zfcg.gxzf.gov.cn"
|
||||
crawler_keywords: list[str] = ["大化"]
|
||||
crawler_max_pages: int = 10
|
||||
crawler_timeout: int = 30
|
||||
crawler_page_size: int = 100
|
||||
|
||||
# 企业微信
|
||||
wechat_enabled: bool = True
|
||||
wechat_corp_id: str = ""
|
||||
wechat_agent_id: str = ""
|
||||
wechat_secret: str = ""
|
||||
wechat_token: str = ""
|
||||
wechat_encoding_aes_key: str = ""
|
||||
wechat_port: int = 18001
|
||||
wechat_host: str = "0.0.0.0"
|
||||
|
||||
# 定时任务
|
||||
scheduler_enabled: bool = True
|
||||
scheduler_cron: str = "0 8,14,18 * * *"
|
||||
|
||||
# Markdown
|
||||
markdown_enabled: bool = True
|
||||
markdown_output_file: str = "onu.md"
|
||||
|
||||
# 公告来源(JSON 字符串,从环境变量读取)
|
||||
announcement_sources: str = '{"ZcyAnnouncement1":{"category_id":66485,"name":"采购公告","type":"purchase"},"ZcyAnnouncement2":{"category_id":66485,"name":"结果公告","type":"result"},"ZcyAnnouncement3":{"category_id":66485,"name":"合同公告","type":"contract"},"ZcyAnnouncement4":{"category_id":66485,"name":"更正公告","type":"correction"},"ZcyAnnouncement5":{"category_id":66485,"name":"招标文件预公示","type":"pre_announcement"},"ZcyAnnouncement6":{"category_id":66485,"name":"单一来源公示","type":"single_source"},"ZcyAnnouncement7":{"category_id":66485,"name":"电子卖场公示","type":"electronic_market"},"ZcyAnnouncement10":{"category_id":66485,"name":"履约验收公示","type":"acceptance"},"ZcyAnnouncement11":{"category_id":66485,"name":"工程类公告","type":"engineering"},"ZcyAnnouncement20":{"category_id":66485,"name":"框架协议征集公告","type":"framework_agreement"},"ZcyAnnouncement21":{"category_id":66485,"name":"框架协议入围结果公告","type":"framework_result"},"ZcyAnnouncement23":{"category_id":66485,"name":"框架协议成交结果汇总公告","type":"framework_summary"},"61-266648":{"category_id":66485,"name":"采购意向公开","type":"intention"}}' # noqa: E501
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,56 @@
|
||||
import hashlib
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlResult:
|
||||
source_code: str
|
||||
source_name: str
|
||||
total_count: int = 0
|
||||
new_count: int = 0
|
||||
announcements: list = field(default_factory=list)
|
||||
error_message: str | None = None
|
||||
crawled_at: datetime = field(default_factory=datetime.now)
|
||||
duration: float = 0.0
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.error_message is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
filter_enabled: bool = True
|
||||
keywords: list[str] = field(default_factory=list)
|
||||
dedup_enabled: bool = True
|
||||
notify_mode: str = "filtered"
|
||||
mark_sent: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
stored: int = 0
|
||||
filtered: int = 0
|
||||
notified: int = 0
|
||||
markdown_generated: bool = False
|
||||
|
||||
|
||||
class BaseSpider(ABC):
|
||||
name: str
|
||||
source_code: str
|
||||
source_name: str
|
||||
|
||||
@abstractmethod
|
||||
async def crawl(self) -> CrawlResult:
|
||||
...
|
||||
|
||||
def get_pipeline_config(self) -> PipelineConfig:
|
||||
return PipelineConfig()
|
||||
|
||||
@staticmethod
|
||||
def generate_content_hash(title: str, publish_date: str, purchase_name: str,
|
||||
content_url: str, source_code: str) -> str:
|
||||
content = f"{title}|{publish_date}|{purchase_name}|{content_url}|{source_code}"
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig
|
||||
from app.crawler.parsers import parse_dahuagov_html
|
||||
|
||||
|
||||
class DahuagovSpider(BaseSpider):
|
||||
name = "dahuagov"
|
||||
source_code = "dahuagov"
|
||||
source_name = "大化县政府网采购公告"
|
||||
|
||||
BASE_URL = "http://www.gxdh.gov.cn"
|
||||
ANNOUNCEMENT_PATH = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/"
|
||||
|
||||
def get_pipeline_config(self) -> PipelineConfig:
|
||||
return PipelineConfig(
|
||||
filter_enabled=False,
|
||||
keywords=[],
|
||||
dedup_enabled=True,
|
||||
notify_mode="all",
|
||||
mark_sent=True,
|
||||
)
|
||||
|
||||
async def crawl(self) -> CrawlResult:
|
||||
start_time = datetime.now()
|
||||
url = self.BASE_URL + self.ANNOUNCEMENT_PATH
|
||||
|
||||
async with httpx.AsyncClient(timeout=settings.crawler_timeout) as client:
|
||||
await self._delay()
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Referer": self.BASE_URL,
|
||||
}
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
error_message=f"HTTP {response.status_code}",
|
||||
crawled_at=start_time,
|
||||
)
|
||||
html = response.text
|
||||
except Exception as e:
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
error_message=str(e),
|
||||
crawled_at=start_time,
|
||||
)
|
||||
|
||||
announcements = parse_dahuagov_html(html, start_time)
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
total_count=len(announcements),
|
||||
new_count=len(announcements),
|
||||
announcements=announcements,
|
||||
crawled_at=start_time,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
async def _delay(self):
|
||||
delay = random.uniform(1.0, 3.0)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig
|
||||
from app.crawler.parsers import extract_pagination, parse_gxgp_api_response
|
||||
|
||||
|
||||
class GXGPSpider(BaseSpider):
|
||||
name = "gxgp"
|
||||
source_code = "gxgp"
|
||||
source_name = "广西政府采购网"
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.crawler_base_url
|
||||
self.announcement_api = f"{self.base_url}/portal/category"
|
||||
|
||||
def get_pipeline_config(self) -> PipelineConfig:
|
||||
return PipelineConfig(
|
||||
filter_enabled=True,
|
||||
keywords=list(settings.crawler_keywords),
|
||||
dedup_enabled=True,
|
||||
notify_mode="filtered",
|
||||
mark_sent=False,
|
||||
)
|
||||
|
||||
async def crawl(self, sources: list[str] | None = None,
|
||||
max_pages: int | None = None) -> CrawlResult:
|
||||
if max_pages is None:
|
||||
max_pages = settings.crawler_max_pages
|
||||
if sources is None:
|
||||
source_map = json.loads(settings.announcement_sources)
|
||||
sources = list(source_map.keys())
|
||||
|
||||
start_time = datetime.now()
|
||||
all_announcements = []
|
||||
error_messages = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=settings.crawler_timeout) as client:
|
||||
for source_code in sources:
|
||||
source_info = json.loads(settings.announcement_sources).get(source_code)
|
||||
if not source_info:
|
||||
continue
|
||||
|
||||
category_id = source_info["category_id"]
|
||||
source_name = source_info["name"]
|
||||
|
||||
for page_no in range(1, max_pages + 1):
|
||||
if page_no > 1:
|
||||
await self._delay()
|
||||
|
||||
try:
|
||||
data = await self._fetch_page(
|
||||
client, source_code, category_id, page_no
|
||||
)
|
||||
if data is None:
|
||||
break
|
||||
|
||||
records = parse_gxgp_api_response(
|
||||
data, source_code, source_name,
|
||||
start_time, category_id
|
||||
)
|
||||
if not records:
|
||||
break
|
||||
|
||||
all_announcements.extend(records)
|
||||
|
||||
pagination = extract_pagination(data)
|
||||
if not pagination["has_next"]:
|
||||
break
|
||||
except Exception as e:
|
||||
error_messages.append(f"{source_code} page {page_no}: {e}")
|
||||
break
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
total_count=len(all_announcements),
|
||||
new_count=len(all_announcements),
|
||||
announcements=all_announcements,
|
||||
error_message="; ".join(error_messages) if error_messages else None,
|
||||
crawled_at=start_time,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
async def _fetch_page(self, client: httpx.AsyncClient, source_code: str,
|
||||
category_id: int, page_no: int) -> dict | None:
|
||||
payload = {
|
||||
"keyword": "",
|
||||
"publishDateBegin": "",
|
||||
"publishDateEnd": "",
|
||||
"pageNo": page_no,
|
||||
"pageSize": settings.crawler_page_size,
|
||||
"categoryCode": source_code,
|
||||
"_t": int(time.time() * 1000),
|
||||
}
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Origin": self.base_url,
|
||||
"Referer": f"{self.base_url}/site/category?parentId={category_id}&childrenCode={source_code}", # noqa: E501
|
||||
}
|
||||
response = await client.post(
|
||||
self.announcement_api, json=payload, headers=headers
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def _delay(self):
|
||||
import asyncio
|
||||
delay = random.uniform(1.0, 3.0)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -0,0 +1,139 @@
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def parse_gxgp_api_response(
|
||||
response_data: dict[str, Any],
|
||||
source_code: str,
|
||||
source_name: str,
|
||||
crawled_at: datetime,
|
||||
category_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not response_data.get("success"):
|
||||
return []
|
||||
data = response_data.get("result", {}).get("data", {})
|
||||
records = data.get("data", [])
|
||||
if not records:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
title = str(record.get("title", "")).strip()
|
||||
if not title:
|
||||
continue
|
||||
|
||||
timestamp = record.get("publishDate")
|
||||
if not timestamp:
|
||||
continue
|
||||
try:
|
||||
publish_date = datetime.fromtimestamp(int(timestamp) / 1000)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
purchase_name = str(record.get("purchaseName", "")).strip()
|
||||
article_id = record.get("articleId")
|
||||
if not article_id:
|
||||
continue
|
||||
|
||||
content_url = (
|
||||
f"https://zfcg.gxzf.gov.cn/site/detail?"
|
||||
f"parentId={category_id}&articleId={article_id}"
|
||||
)
|
||||
|
||||
announce = {
|
||||
"title": title,
|
||||
"publish_date": publish_date,
|
||||
"purchase_name": purchase_name,
|
||||
"content_url": content_url,
|
||||
"source_code": source_code,
|
||||
"source_name": source_name,
|
||||
"announcement_type": "purchase",
|
||||
"crawl_mode": "auto",
|
||||
"is_new": True,
|
||||
"is_today": publish_date.date() == datetime.now().date(),
|
||||
}
|
||||
announce["content_hash"] = _generate_hash(announce)
|
||||
results.append(announce)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def extract_pagination(response_data: dict[str, Any]) -> dict[str, Any]:
|
||||
data = response_data.get("result", {}).get("data", {})
|
||||
return {
|
||||
"total": data.get("total", 0),
|
||||
"page_no": data.get("pageNo", 1),
|
||||
"page_size": data.get("pageSize", 100),
|
||||
"pages": data.get("pages", 0),
|
||||
"empty": data.get("empty", True),
|
||||
"has_next": data.get("hasNext", False),
|
||||
"has_previous": data.get("hasPrevious", False),
|
||||
}
|
||||
|
||||
|
||||
def parse_dahuagov_html(html: str, crawled_at: datetime) -> list[dict[str, Any]]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
lists = soup.find_all("ul", class_="more-list")
|
||||
if not lists:
|
||||
return []
|
||||
|
||||
results = []
|
||||
base_url = "http://www.gxdh.gov.cn"
|
||||
base_path = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/"
|
||||
|
||||
for ul in lists:
|
||||
for li in ul.find_all("li"):
|
||||
date_span = li.find("span")
|
||||
if not date_span:
|
||||
continue
|
||||
date_text = date_span.get_text(strip=True)
|
||||
try:
|
||||
publish_date = datetime.strptime(date_text, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
link_tag = li.find("a")
|
||||
if not link_tag:
|
||||
continue
|
||||
title = link_tag.get("title", "") or link_tag.get_text(strip=True)
|
||||
href = link_tag.get("href", "")
|
||||
if not title or not href:
|
||||
continue
|
||||
|
||||
if href.startswith("./") or href.startswith("../"):
|
||||
content_url = urljoin(base_url + base_path, href)
|
||||
elif href.startswith("/"):
|
||||
content_url = base_url + href
|
||||
elif href.startswith("http"):
|
||||
content_url = href
|
||||
else:
|
||||
content_url = urljoin(base_url + base_path, href)
|
||||
|
||||
announce = {
|
||||
"title": title,
|
||||
"publish_date": publish_date,
|
||||
"purchase_name": "大化瑶族自治县",
|
||||
"content_url": content_url,
|
||||
"source_code": "dahuagov",
|
||||
"source_name": "大化县政府网采购公告",
|
||||
"announcement_type": "purchase",
|
||||
"crawl_mode": "auto",
|
||||
"is_new": True,
|
||||
"is_today": publish_date.date() == datetime.now().date(),
|
||||
}
|
||||
announce["content_hash"] = _generate_hash(announce)
|
||||
results.append(announce)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _generate_hash(ann: dict[str, Any]) -> str:
|
||||
content = (
|
||||
f"{ann['title']}|{ann['publish_date'].strftime('%Y-%m-%d')}"
|
||||
f"|{ann['purchase_name']}|{ann['content_url']}|{ann['source_code']}"
|
||||
)
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=settings.debug)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
from app.scheduler.jobs import shutdown_scheduler, start_scheduler
|
||||
start_scheduler()
|
||||
yield
|
||||
shutdown_scheduler()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="广西政府采购网公告监控系统",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.debug else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,42 @@
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Announcement(Base):
|
||||
__tablename__ = "announcements"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
publish_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
purchase_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
content_url: Mapped[str] = mapped_column(Text, default="")
|
||||
source_code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
source_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
announcement_type: Mapped[str] = mapped_column(String(50), default="purchase")
|
||||
content_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
crawl_mode: Mapped[str] = mapped_column(String(20), default="auto")
|
||||
is_new: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
keyword_matched: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # noqa: E501
|
||||
|
||||
@staticmethod
|
||||
def generate_hash(title: str, publish_date: str, purchase_name: str,
|
||||
content_url: str, source_code: str) -> str:
|
||||
content = f"{title}|{publish_date}|{purchase_name}|{content_url}|{source_code}"
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def source_map() -> dict:
|
||||
import json
|
||||
|
||||
from app.config import settings
|
||||
return json.loads(settings.announcement_sources)
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnnouncementResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
publish_date: datetime
|
||||
purchase_name: str
|
||||
content_url: str
|
||||
source_code: str
|
||||
source_name: str
|
||||
announcement_type: str
|
||||
crawl_mode: str
|
||||
is_new: bool
|
||||
is_sent: bool
|
||||
keyword_matched: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AnnouncementListResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: list[AnnouncementResponse]
|
||||
|
||||
|
||||
class CrawlTriggerRequest(BaseModel):
|
||||
keywords: list[str] | None = None
|
||||
sources: list[str] | None = None
|
||||
manual: bool = False
|
||||
|
||||
|
||||
class CrawlStatusResponse(BaseModel):
|
||||
running: bool
|
||||
last_crawl_time: datetime | None = None
|
||||
total_sources: int
|
||||
|
||||
|
||||
class SourceInfo(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class SourcesResponse(BaseModel):
|
||||
sources: list[SourceInfo]
|
||||
|
||||
|
||||
class JobResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
next_run_time: str | None = None
|
||||
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from app.api.deps import get_crawl_service
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def scheduled_crawl():
|
||||
logger.info("开始定时爬取任务")
|
||||
service = get_crawl_service()
|
||||
names = service.get_spider_names()
|
||||
for name in names:
|
||||
try:
|
||||
results = await service.run_spider(name)
|
||||
for r in results:
|
||||
if not r.success:
|
||||
logger.error(f"Spider {name} 失败: {r.error_message}")
|
||||
else:
|
||||
logger.info(f"Spider {name} 完成: {r.total_count} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"Spider {name} 异常: {e}")
|
||||
logger.info("定时爬取任务完成")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
if not settings.scheduler_enabled:
|
||||
return
|
||||
scheduler.add_job(
|
||||
scheduled_crawl,
|
||||
"cron",
|
||||
hour="8,14,18",
|
||||
minute="0",
|
||||
id="scheduled_crawl",
|
||||
name="定时爬取",
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("APScheduler 已启动 (8:00, 14:00, 18:00)")
|
||||
|
||||
|
||||
def shutdown_scheduler():
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("APScheduler 已停止")
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
from app.crawler.base import BaseSpider, CrawlResult
|
||||
|
||||
|
||||
class CrawlService:
|
||||
def __init__(self):
|
||||
self.spiders: dict[str, BaseSpider] = {}
|
||||
|
||||
def register(self, spider: BaseSpider):
|
||||
self.spiders[spider.name] = spider
|
||||
|
||||
async def run_all(self) -> list[CrawlResult]:
|
||||
results = []
|
||||
for name, spider in self.spiders.items():
|
||||
result = await spider.crawl()
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def run_spider(self, name: str, **kwargs) -> list[CrawlResult]:
|
||||
spider = self.spiders.get(name)
|
||||
if spider is None:
|
||||
return [CrawlResult(
|
||||
source_code=name, source_name=name,
|
||||
error_message=f"Spider not found: {name}"
|
||||
)]
|
||||
result = await spider.crawl(**kwargs)
|
||||
return [result]
|
||||
|
||||
def get_spider_names(self) -> list[str]:
|
||||
return list(self.spiders.keys())
|
||||
|
||||
def get_pipeline_config(self, name: str):
|
||||
spider = self.spiders.get(name)
|
||||
if spider:
|
||||
return spider.get_pipeline_config()
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
|
||||
def filter_by_keywords(announcements: list[dict[str, Any]],
|
||||
keywords: list[str]) -> list[dict[str, Any]]:
|
||||
if not keywords:
|
||||
return announcements
|
||||
|
||||
filtered = []
|
||||
for ann in announcements:
|
||||
search_text = f"{ann.get('title', '')} {ann.get('purchase_name', '')}"
|
||||
if any(kw in search_text for kw in keywords):
|
||||
ann["keyword_matched"] = True
|
||||
filtered.append(ann)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def filter_by_date(announcements: list[dict[str, Any]],
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None) -> list[dict[str, Any]]:
|
||||
if not start_date and not end_date:
|
||||
return announcements
|
||||
|
||||
filtered = []
|
||||
for ann in announcements:
|
||||
pub_date = ann.get("publish_date")
|
||||
if not pub_date:
|
||||
continue
|
||||
if not isinstance(pub_date, date):
|
||||
pub_date = pub_date.date() if hasattr(pub_date, "date") else None
|
||||
if pub_date is None:
|
||||
continue
|
||||
|
||||
if start_date and pub_date < start_date:
|
||||
continue
|
||||
if end_date and pub_date > end_date:
|
||||
continue
|
||||
filtered.append(ann)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def dedup_by_hash(announcements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen = set()
|
||||
result = []
|
||||
for ann in announcements:
|
||||
h = ann.get("content_hash")
|
||||
if h and h not in seen:
|
||||
seen.add(h)
|
||||
result.append(ann)
|
||||
return result
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.wechat.client import WeChatClient
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self):
|
||||
self.client = WeChatClient()
|
||||
|
||||
async def send(self, announcements: list[dict[str, Any]]) -> int:
|
||||
if not settings.wechat_enabled:
|
||||
return 0
|
||||
if not announcements:
|
||||
return 0
|
||||
|
||||
sent = 0
|
||||
for ann in announcements:
|
||||
try:
|
||||
title = ann.get("title", "")
|
||||
if len(title) > 128:
|
||||
title = title[:125] + "..."
|
||||
|
||||
purchase_name = ann.get("purchase_name", "")
|
||||
if len(purchase_name) > 25:
|
||||
purchase_name = purchase_name[:22] + "..."
|
||||
|
||||
pub_date = ann.get("publish_date")
|
||||
time_str = pub_date.strftime("%Y-%m-%d %H:%M") if pub_date else "时间未知"
|
||||
|
||||
source_name = ann.get("source_name", "")
|
||||
|
||||
description = (
|
||||
f'<div style="font-size: 14px; margin-top: 8px;">'
|
||||
f'{source_name} | {purchase_name} | {time_str}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
url = ann.get("content_url", "")
|
||||
|
||||
if await self.client.send_textcard(title, description, url):
|
||||
sent += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return sent
|
||||
@@ -0,0 +1,105 @@
|
||||
from typing import Any
|
||||
|
||||
from app.crawler.base import PipelineConfig, PipelineResult
|
||||
from app.services.filter_service import dedup_by_hash
|
||||
|
||||
|
||||
class PostCrawlPipeline:
|
||||
def __init__(self, db_session, notification_service):
|
||||
self.db = db_session
|
||||
self.notify = notification_service
|
||||
|
||||
async def process(self, announcements: list[dict[str, Any]],
|
||||
config: PipelineConfig) -> PipelineResult:
|
||||
result = PipelineResult()
|
||||
|
||||
if not announcements:
|
||||
return result
|
||||
|
||||
# 1. Dedup
|
||||
if config.dedup_enabled:
|
||||
announcements = dedup_by_hash(announcements)
|
||||
|
||||
# 2. Store to database
|
||||
stored = await self._save_to_db(announcements)
|
||||
result.stored = stored
|
||||
|
||||
to_notify = announcements
|
||||
|
||||
# 3. Filter
|
||||
if config.filter_enabled and config.keywords:
|
||||
before = len(to_notify)
|
||||
to_notify = [a for a in to_notify
|
||||
if self._match_keywords(a, config.keywords)]
|
||||
result.filtered = before - len(to_notify)
|
||||
|
||||
# 4. Notify
|
||||
if config.notify_mode == "all":
|
||||
result.notified = await self._send_notifications(to_notify)
|
||||
elif config.notify_mode == "filtered":
|
||||
if config.filter_enabled and config.keywords:
|
||||
result.notified = await self._send_notifications(to_notify)
|
||||
elif not config.filter_enabled:
|
||||
result.notified = await self._send_notifications(to_notify)
|
||||
|
||||
# 5. Mark sent
|
||||
if config.mark_sent and result.notified > 0:
|
||||
await self._mark_sent(to_notify)
|
||||
|
||||
return result
|
||||
|
||||
async def _save_to_db(self, announcements: list[dict[str, Any]]) -> int:
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from app.models.announcement import Announcement
|
||||
|
||||
if not announcements:
|
||||
return 0
|
||||
|
||||
values = [{
|
||||
"title": a["title"],
|
||||
"publish_date": a["publish_date"],
|
||||
"purchase_name": a.get("purchase_name", ""),
|
||||
"content_url": a.get("content_url", ""),
|
||||
"source_code": a["source_code"],
|
||||
"source_name": a["source_name"],
|
||||
"announcement_type": a.get("announcement_type", "purchase"),
|
||||
"content_hash": a["content_hash"],
|
||||
"crawl_mode": a.get("crawl_mode", "auto"),
|
||||
"is_new": a.get("is_new", True),
|
||||
"is_sent": False,
|
||||
"keyword_matched": a.get("keyword_matched", False),
|
||||
} for a in announcements]
|
||||
|
||||
stmt = insert(Announcement).values(values)
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=["content_hash"])
|
||||
|
||||
result_proxy = await self.db.execute(stmt)
|
||||
await self.db.commit()
|
||||
return result_proxy.rowcount if result_proxy.rowcount >= 0 else len(values)
|
||||
|
||||
async def _send_notifications(self, announcements: list[dict[str, Any]]) -> int:
|
||||
return await self.notify.send(announcements)
|
||||
|
||||
async def _mark_sent(self, announcements: list[dict[str, Any]]) -> int:
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models.announcement import Announcement
|
||||
|
||||
hashes = [a["content_hash"] for a in announcements if a.get("content_hash")]
|
||||
if not hashes:
|
||||
return 0
|
||||
|
||||
stmt = (
|
||||
update(Announcement)
|
||||
.where(Announcement.content_hash.in_(hashes))
|
||||
.values(is_sent=True)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
await self.db.commit()
|
||||
return result.rowcount
|
||||
|
||||
@staticmethod
|
||||
def _match_keywords(announcement: dict[str, Any], keywords: list[str]) -> bool:
|
||||
text = f"{announcement.get('title', '')} {announcement.get('purchase_name', '')}"
|
||||
return any(kw in text for kw in keywords)
|
||||
@@ -0,0 +1,76 @@
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class WeChatClient:
|
||||
def __init__(self):
|
||||
self._access_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
|
||||
async def _get_access_token(self) -> str | None:
|
||||
now = time.time()
|
||||
if self._access_token and now < self._token_expires_at:
|
||||
return self._access_token
|
||||
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": settings.wechat_corp_id,
|
||||
"corpsecret": settings.wechat_secret,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(url, params=params)
|
||||
data = response.json()
|
||||
if data.get("errcode") == 0:
|
||||
self._access_token = data["access_token"]
|
||||
self._token_expires_at = now + data.get("expires_in", 7200) - 300
|
||||
return self._access_token
|
||||
return None
|
||||
|
||||
async def send_text(self, content: str, to_user: str = "@all") -> bool:
|
||||
return await self._send_message("text", {"content": content}, to_user)
|
||||
|
||||
async def send_markdown(self, content: str, to_user: str = "@all") -> bool:
|
||||
return await self._send_message("markdown", {"content": content}, to_user)
|
||||
|
||||
async def send_textcard(
|
||||
self,
|
||||
title: str,
|
||||
description: str,
|
||||
url: str,
|
||||
to_user: str = "@all",
|
||||
btn_txt: str = "查看详情",
|
||||
) -> bool:
|
||||
return await self._send_message(
|
||||
"textcard",
|
||||
{
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"btntxt": btn_txt,
|
||||
},
|
||||
to_user,
|
||||
)
|
||||
|
||||
async def _send_message(
|
||||
self, msgtype: str, msg_data: dict, to_user: str = "@all"
|
||||
) -> bool:
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
return False
|
||||
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
||||
params = {"access_token": token}
|
||||
body = {
|
||||
"touser": to_user,
|
||||
"msgtype": msgtype,
|
||||
"agentid": int(settings.wechat_agent_id),
|
||||
msgtype: msg_data,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, params=params, json=body)
|
||||
data = response.json()
|
||||
return data.get("errcode") == 0
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2014 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
try:
|
||||
import ierror
|
||||
except ImportError:
|
||||
from . import ierror
|
||||
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
|
||||
|
||||
class FormatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def throw_exception(message, exception_class=FormatError):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode())
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class XMLParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# xml消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
|
||||
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
|
||||
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
|
||||
<TimeStamp>%(timestamp)s</TimeStamp>
|
||||
<Nonce><![CDATA[%(nonce)s]]></Nonce>
|
||||
</xml>"""
|
||||
|
||||
def extract(self, xmltext):
|
||||
"""提取出xml数据包中的加密消息
|
||||
@param xmltext: 待提取的xml字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
xml_tree = ET.fromstring(xmltext)
|
||||
encrypt = xml_tree.find("Encrypt")
|
||||
return ierror.WXBizMsgCrypt_OK, encrypt.text
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ParseXml_Error, None
|
||||
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成xml消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的xml字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt': encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp': timestamp,
|
||||
'nonce': nonce,
|
||||
}
|
||||
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_xml
|
||||
|
||||
|
||||
class PKCS7Encoder:
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + (pad * amount_to_pad).encode()
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad < 1 or pad > 32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt:
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self, key):
|
||||
|
||||
# self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
def encrypt(self, text, receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = text.encode()
|
||||
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode() # noqa: E501
|
||||
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self, text, receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error, None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
# 去掉补位字符串
|
||||
# pkcs7 = PKCS7Encoder()
|
||||
# plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0])
|
||||
xml_content = content[4: xml_len + 4]
|
||||
from_receiveid = content[xml_len + 4:]
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer, None
|
||||
|
||||
if from_receiveid.decode('utf8') != receiveid:
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||
return 0, xml_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WXBizMsgCrypt:
|
||||
# 构造函数
|
||||
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey + "=")
|
||||
assert len(self.key) == 32
|
||||
except Exception:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatError)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
# 验证URL
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sEchoStr: 随机串,对应URL参数的echostr
|
||||
# @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
# @return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
|
||||
return ret, sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
|
||||
# 将企业回复用户的消息加密打包
|
||||
# @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串
|
||||
# @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 # noqa: E501
|
||||
# @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce # noqa: E501
|
||||
# sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, # noqa: E501
|
||||
# return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode('utf8')
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
xmlParse = XMLParse()
|
||||
return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# xml_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
xmlParse = XMLParse()
|
||||
ret, encrypt = xmlParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)
|
||||
return ret, xml_content
|
||||
@@ -0,0 +1,57 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from app.config import settings
|
||||
from app.wechat.crypto import WXBizMsgCrypt
|
||||
|
||||
|
||||
class WeChatMessageHandler:
|
||||
def __init__(self):
|
||||
self.wxcpt = WXBizMsgCrypt(
|
||||
sToken=settings.wechat_token,
|
||||
sEncodingAESKey=settings.wechat_encoding_aes_key,
|
||||
sReceiveId=settings.wechat_corp_id,
|
||||
)
|
||||
|
||||
def verify_url(
|
||||
self, msg_signature: str, timestamp: str, nonce: str, echostr: str
|
||||
) -> str | None:
|
||||
ret, sEchoStr = self.wxcpt.VerifyURL(
|
||||
msg_signature, timestamp, nonce, echostr
|
||||
)
|
||||
if ret == 0:
|
||||
return (
|
||||
sEchoStr.decode("utf-8")
|
||||
if isinstance(sEchoStr, bytes)
|
||||
else sEchoStr
|
||||
)
|
||||
return None
|
||||
|
||||
def decrypt_message(
|
||||
self,
|
||||
post_data: str,
|
||||
msg_signature: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
) -> ET.Element | None:
|
||||
ret, xml_content = self.wxcpt.DecryptMsg(
|
||||
post_data, msg_signature, timestamp, nonce
|
||||
)
|
||||
if ret != 0:
|
||||
return None
|
||||
return ET.fromstring(xml_content)
|
||||
|
||||
def encrypt_response(
|
||||
self, response_xml: str, nonce: str, timestamp: str
|
||||
) -> str | None:
|
||||
ret, encrypted = self.wxcpt.EncryptMsg(response_xml, nonce, timestamp)
|
||||
if ret == 0:
|
||||
return encrypted
|
||||
return None
|
||||
|
||||
def handle_event(
|
||||
self, event: str, event_key: str | None, from_user: str
|
||||
) -> str | None:
|
||||
return None
|
||||
|
||||
def handle_text(self, content: str, from_user: str) -> str | None:
|
||||
return None
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseXml_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnXml_Error = -40011
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ../.env
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
restart: unless-stopped
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
# FastAPI + Docker 迁移设计文档
|
||||
|
||||
**日期**: 2026-05-09
|
||||
**版本**: 1.0
|
||||
**状态**: 设计阶段
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
将现有的广西政府采购网公告监控系统从 Flask CLI 架构迁移到 FastAPI + Docker 部署方案,同时提升代码质量、安全性和可维护性。
|
||||
|
||||
### 核心目标
|
||||
|
||||
- FastAPI 替代 Flask 回调服务器 + argparse CLI,统一为一个 Web 应用
|
||||
- Docker 单容器部署,连接现有 PostgreSQL
|
||||
- SQLAlchemy ORM 替代原始 psycopg2 操作
|
||||
- APScheduler 内置定时任务
|
||||
- 环境变量管理配置,敏感信息不提交 git
|
||||
- 异步改造(httpx + asyncpg)
|
||||
|
||||
---
|
||||
|
||||
## 2. 新项目结构
|
||||
|
||||
```
|
||||
gx-gp-notify/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 应用入口 + lifespan
|
||||
│ ├── config.py # Pydantic Settings (环境变量驱动)
|
||||
│ ├── api/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # 统一路由注册
|
||||
│ │ ├── deps.py # 依赖注入 (get_db, get_scheduler)
|
||||
│ │ ├── announcements.py # 公告 CRUD 端点
|
||||
│ │ ├── crawl.py # 爬取触发端点
|
||||
│ │ └── wechat.py # 企业微信回调端点
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── announcement.py # SQLAlchemy 模型
|
||||
│ │ └── schemas.py # Pydantic 请求/响应模型
|
||||
│ ├── crawler/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # Spider 抽象基类 + Pipeline 配置
|
||||
│ │ ├── gxgp_spider.py # 广西政府采购网爬虫 (API 模式)
|
||||
│ │ ├── dahuagov_spider.py # 大化县政府网爬虫 (HTML 模式)
|
||||
│ │ └── parsers.py # 数据解析器
|
||||
│ ├── services/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── crawl_service.py # 爬取编排器 (注册/调用所有 Spider)
|
||||
│ │ ├── pipeline.py # 统一后处理管道 (存储→筛选→推送)
|
||||
│ │ ├── filter_service.py # 筛选逻辑
|
||||
│ │ └── notification_service.py # 通知服务
|
||||
│ ├── wechat/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── handler.py # 消息处理器
|
||||
│ │ ├── menu.py # 菜单管理
|
||||
│ │ ├── crypto.py # WXBizMsgCrypt 加解密
|
||||
│ │ └── client.py # 企业微信 API 客户端
|
||||
│ └── scheduler/
|
||||
│ ├── __init__.py
|
||||
│ └── jobs.py # APScheduler 定时任务
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # pytest fixtures
|
||||
│ ├── test_crawler/ # 爬虫解析器测试
|
||||
│ ├── test_api/ # API 端点测试
|
||||
│ └── test_services/ # 业务逻辑测试
|
||||
├── alembic/ # 数据库迁移
|
||||
│ └── versions/
|
||||
├── docker/
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
├── pyproject.toml # 项目元数据 + 依赖 + 工具配置
|
||||
├── .env.example # 环境变量模板
|
||||
├── .gitignore
|
||||
└── logs/ # 日志目录 (gitignore)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 爬虫模块化设计
|
||||
|
||||
### 3.1 设计动机
|
||||
|
||||
系统爬取两个不同来源的公告:广西政府采购网(API 模式,13 种公告类型)和大化县政府网(HTML 解析,单一来源)。两个爬虫的后处理流程不同:
|
||||
|
||||
| 对比 | 广西政府采购网 | 大化县政府网 |
|
||||
|------|---------------|-------------|
|
||||
| 爬取方式 | POST JSON API | GET HTML + BeautifulSoup |
|
||||
| 反爬机制 | 敏感词检查 | 无 |
|
||||
| 分页 | 多页轮询 | 单页 |
|
||||
| 筛选策略 | 关键词 + 日期 + 去重 | 不过滤 |
|
||||
| 推送策略 | 只推送关键词匹配的 | 全部推送 |
|
||||
| 已发送标记 | is_new 标记 | is_new → mark_sent |
|
||||
|
||||
为避免两个爬虫各自维护一套存储/筛选/推送逻辑,采用 **Spider 接口 + Pipeline 策略模式** 分离关注点。
|
||||
|
||||
### 3.2 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CrawlOrchestrator │
|
||||
│ (注册所有 Spider,统一调度爬取) │
|
||||
└─────────────────────┬───────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
│ │ │
|
||||
┌───────▼──────┐ ┌────▼──────┐ ┌────▼──────────┐
|
||||
│ GXGPSpider │ │ Dahuagov │ │ (FutureSpider) │
|
||||
│ (API 模式) │ │ Spider │ │ │
|
||||
│ 13 sources │ │ (HTML模式) │ │ │
|
||||
└───────┬──────┘ └────┬──────┘ └────┬───────────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
│ List[Announcement]
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────┐
|
||||
│ PostCrawlPipeline │
|
||||
│ │
|
||||
│ 每个 Spider 声明 PipelineConfig: │
|
||||
│ - filter_enabled: 是否启用关键词筛选 │
|
||||
│ - keywords: 筛选关键词列表 │
|
||||
│ - dedup_enabled: 是否去重 │
|
||||
│ - notify_mode: "filtered" | "all" │
|
||||
│ - mark_sent: 推送后是否标记已发送 │
|
||||
│ │
|
||||
│ 统一处理流程: │
|
||||
│ 存储 → 去重 → 筛选(可选) → 推送(可选) → 标记(可选) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 Spider 基类
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
"""Spider 后处理策略配置"""
|
||||
filter_enabled: bool = True
|
||||
keywords: List[str] = None
|
||||
dedup_enabled: bool = True
|
||||
notify_mode: str = "filtered" # "filtered" | "all"
|
||||
mark_sent: bool = False
|
||||
|
||||
|
||||
class BaseSpider(ABC):
|
||||
"""爬虫基类 — 只负责爬取+解析,不管后续如何处理"""
|
||||
|
||||
name: str # spider 名称
|
||||
source_code: str # 来源代码
|
||||
source_name: str # 来源名称
|
||||
|
||||
@abstractmethod
|
||||
async def crawl(self) -> CrawlResult:
|
||||
"""执行爬取,返回包含 Announcement 列表的 CrawlResult"""
|
||||
...
|
||||
|
||||
def get_pipeline_config(self) -> PipelineConfig:
|
||||
"""返回后处理策略(子类可覆盖)"""
|
||||
return PipelineConfig()
|
||||
```
|
||||
|
||||
### 3.4 两个 Spider 的 PipelineConfig
|
||||
|
||||
```python
|
||||
# GXGPSpider — 关键词筛选模式
|
||||
PipelineConfig(
|
||||
filter_enabled=True,
|
||||
keywords=["大化"], # 从配置读取
|
||||
dedup_enabled=True,
|
||||
notify_mode="filtered", # 只推送匹配关键词的
|
||||
mark_sent=False, # 用 is_new 标记,不单独 mark_sent
|
||||
)
|
||||
|
||||
# DahuagovSpider — 全量推送模式
|
||||
PipelineConfig(
|
||||
filter_enabled=False,
|
||||
keywords=[],
|
||||
dedup_enabled=True,
|
||||
notify_mode="all", # 全部推送
|
||||
mark_sent=True, # 推送后标记已发送
|
||||
)
|
||||
```
|
||||
|
||||
### 3.5 统一后处理管道
|
||||
|
||||
```python
|
||||
class PostCrawlPipeline:
|
||||
"""统一后处理管道 — 所有 Spider 共用"""
|
||||
|
||||
async def process(self, announcements: List[Announcement],
|
||||
config: PipelineConfig) -> PipelineResult:
|
||||
# 1. 统一存入 announcements 表 (按 source_code 区分)
|
||||
# 2. 去重 (基于 content_hash)
|
||||
# 3. 根据 config.filter_enabled 决定是否筛选
|
||||
# 4. 根据 config.notify_mode 决定推送策略
|
||||
# - "all": 推送全部新公告
|
||||
# - "filtered": 只推送匹配关键词的
|
||||
# 5. 根据 config.mark_sent 标记已发送
|
||||
# 6. 生成 Markdown (可选)
|
||||
...
|
||||
```
|
||||
|
||||
### 3.6 优势
|
||||
|
||||
- **新增爬虫源**只需实现 `BaseSpider.crawl()` + 声明 `PipelineConfig`,推送/存储逻辑零改动
|
||||
- **推送逻辑单一入口**,修改一处对所有源生效
|
||||
- 每个 Spider 文件只关注"怎么爬"和"怎么解析",不再包含存储/筛选/推送代码
|
||||
- `dahuagov_announcements` 独立表可合并到统一的 `announcements` 表,用 `source_code='dahuagov'` 区分
|
||||
|
||||
---
|
||||
|
||||
## 4. API 端点设计
|
||||
|
||||
### 3.1 公告查询
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/announcements` | 查询公告列表(分页、来源筛选、日期筛选、关键词搜索) |
|
||||
| `GET` | `/api/v1/announcements/{id}` | 获取单条公告详情 |
|
||||
| `GET` | `/api/v1/announcements/today` | 获取今日公告 |
|
||||
| `GET` | `/api/v1/announcements/stats` | 获取公告统计信息 |
|
||||
|
||||
### 3.2 爬取控制
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/v1/crawl/trigger` | 手动触发一次爬取(支持关键词/来源参数) |
|
||||
| `GET` | `/api/v1/crawl/status` | 查看最近爬取状态 |
|
||||
| `GET` | `/api/v1/crawl/sources` | 获取已配置的公告来源列表 |
|
||||
|
||||
### 3.3 定时任务
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/scheduler/jobs` | 查看活跃的定时任务 |
|
||||
| `POST` | `/api/v1/scheduler/pause/{job_id}` | 暂停某个定时任务 |
|
||||
| `POST` | `/api/v1/scheduler/resume/{job_id}` | 恢复某个定时任务 |
|
||||
|
||||
### 3.4 企业微信
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` `POST` | `/api/v1/wechat/callback` | 企业微信回调入口(URL验证 + 消息接收) |
|
||||
|
||||
### 3.5 系统
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/health` | 健康检查(包含数据库连通性) |
|
||||
|
||||
### 3.6 通用特性
|
||||
|
||||
- FastAPI 自动生成 Swagger UI (`/docs`) 和 ReDoc (`/redoc`)
|
||||
- 统一分页格式:`{ total, page, page_size, items }`
|
||||
- 统一错误响应:`{ detail: "error message" }`
|
||||
- 依赖注入:数据库 session、配置、调度器均通过 FastAPI `Depends()` 注入
|
||||
- CORS 默认关闭,通过 `CORS_ORIGINS` 环境变量可选开启
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
### 5.1 公告表 (announcements)
|
||||
|
||||
合并现有的 `announcements`、`auto_announcements`、`manual_announcements`、`dahuagov_announcements` 四张表为一张,通过 `source_code` 字段区分来源,通过 `PipelineConfig` 控制每个来源的推送策略。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Integer | 主键 |
|
||||
| title | String(500) | 公告标题 |
|
||||
| publish_date | DateTime | 发布时间 |
|
||||
| purchase_name | String(200) | 发布单位 |
|
||||
| content_url | Text | 内容链接 |
|
||||
| source_code | String(50) | 来源代码(ZcyAnnouncement1 / dahuagov 等) |
|
||||
| source_name | String(100) | 来源名称 |
|
||||
| announcement_type | String(50) | 公告类型枚举值 |
|
||||
| content_hash | String(64) | SHA256 内容哈希,UNIQUE INDEX |
|
||||
| crawl_mode | String(20) | "auto" / "manual" |
|
||||
| is_new | Boolean | 是否新公告 |
|
||||
| is_sent | Boolean | 是否已推送 |
|
||||
| keyword_matched | Boolean | 是否匹配关键词 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
| updated_at | DateTime | 更新时间 |
|
||||
|
||||
### 5.2 设计决策
|
||||
|
||||
- **四表合一**:不再为不同来源或抓取模式创建独立表。`source_code` 区分来源(`ZcyAnnouncement1` ~ `dahuagov`),`crawl_mode` 区分自动/手动
|
||||
- **is_sent 字段**:替代原来大化县的 `is_new → mark_sent` 模式,同时为广西采购网的公告提供统一的已推送标记
|
||||
- **keyword_matched 字段**:在筛选阶段标记,持久化到数据库方便后续查询
|
||||
|
||||
### 5.3 变更说明
|
||||
|
||||
- `content_hash` 从 MD5 (32位) 改为 SHA256 (64位)
|
||||
- 去掉 `announcement_sources` 表,来源信息从环境变量配置动态读取
|
||||
- 去掉 `crawl_results` 表,爬取结果通过日志记录
|
||||
- 使用 Alembic 管理数据库迁移
|
||||
- 旧数据迁移:通过 Alembic migration 脚本将现有四张表的数据合并到新表
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置管理
|
||||
|
||||
### 5.1 pydantic-settings
|
||||
|
||||
所有配置通过 `pydantic-settings` 的 `BaseSettings` 加载,来源优先级:环境变量 > `.env` 文件 > 默认值。
|
||||
|
||||
### 5.2 环境变量
|
||||
|
||||
```bash
|
||||
# 应用
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 数据库(连接现有 PostgreSQL)
|
||||
DATABASE_URL=postgresql+asyncpg://gx-gp-notify:password@10.10.10.14:5432/gx-gp-notify
|
||||
|
||||
# 爬虫
|
||||
CRAWLER_BASE_URL=https://zfcg.gxzf.gov.cn
|
||||
CRAWLER_KEYWORDS=["大化"]
|
||||
CRAWLER_MAX_PAGES=10
|
||||
CRAWLER_TIMEOUT=30
|
||||
|
||||
# 企业微信
|
||||
WECHAT_ENABLED=true
|
||||
WECHAT_CORP_ID=ww69e8e44636f47780
|
||||
WECHAT_AGENT_ID=1000007
|
||||
WECHAT_SECRET=<secret>
|
||||
WECHAT_TOKEN=<token>
|
||||
WECHAT_ENCODING_AES_KEY=<aes_key>
|
||||
|
||||
# 定时任务
|
||||
SCHEDULER_ENABLED=true
|
||||
SCHEDULER_CRON=0 8,14,18 * * *
|
||||
|
||||
# Markdown 输出
|
||||
MARKDOWN_OUTPUT_FILE=onu.md
|
||||
```
|
||||
|
||||
### 5.3 安全措施
|
||||
|
||||
- `.env` 文件加入 `.gitignore`
|
||||
- `config.yaml` 中的敏感信息不再提交到仓库
|
||||
- 使用 SHA256 替代 MD5 做内容哈希
|
||||
- 建议清理 git 历史中的敏感信息
|
||||
|
||||
---
|
||||
|
||||
## 6. Docker 部署
|
||||
|
||||
### 6.1 架构
|
||||
|
||||
```
|
||||
docker-compose.yml
|
||||
┌─────────────────────────────┐
|
||||
│ app (FastAPI) │
|
||||
│ - uvicorn 服务器 │
|
||||
│ - APScheduler (内置) │
|
||||
│ - 端口 8000 │
|
||||
│ - 挂载: ./logs │
|
||||
└──────────┬──────────────────┘
|
||||
│ TCP 连接
|
||||
┌──────────▼──────────────────┐
|
||||
│ 外部 PostgreSQL (现有) │
|
||||
│ 10.10.10.14:5432 │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
### 6.3 docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 6.4 启动步骤
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 编辑 .env 填入真实配置
|
||||
docker compose up -d # 启动服务
|
||||
docker compose logs -f app # 查看日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 技术栈
|
||||
|
||||
| 组件 | 当前 | 迁移后 |
|
||||
|------|------|--------|
|
||||
| Web 框架 | Flask (仅回调用) | FastAPI |
|
||||
| CLI | argparse | 不再需要,API 替代 |
|
||||
| 数据库驱动 | psycopg2 (同步) | asyncpg (异步) + SQLAlchemy 2.0 |
|
||||
| HTTP 客户端 | requests (同步) | httpx (异步) |
|
||||
| 定时任务 | schedule 库 | APScheduler |
|
||||
| 配置 | PyYAML + config.yaml | pydantic-settings + .env |
|
||||
| 加密 | pycryptodome + cryptography | 保留 |
|
||||
| 部署 | 裸机 + uWSGI | Docker + uvicorn |
|
||||
| 代码检查 | 无 | ruff |
|
||||
| 测试 | 无 | pytest + httpx |
|
||||
| 迁移 | 手动 CREATE TABLE | Alembic |
|
||||
|
||||
---
|
||||
|
||||
## 8. 额外改进
|
||||
|
||||
### 8.1 异步改造
|
||||
|
||||
- 爬虫网络请求:`httpx.AsyncClient` 替代 `requests`
|
||||
- 数据库:`asyncpg` + SQLAlchemy async engine
|
||||
- 通知发送:`httpx.AsyncClient`
|
||||
- API 端点使用 `async def`
|
||||
|
||||
### 8.2 自动化测试
|
||||
|
||||
- 爬虫解析器单元测试(Mock 网页响应,验证解析逻辑)
|
||||
- 筛选逻辑单元测试
|
||||
- API 端点集成测试(用 `httpx.AsyncClient` + 测试数据库)
|
||||
- pytest + pytest-asyncio + pytest-cov
|
||||
|
||||
### 8.3 日志改进
|
||||
|
||||
- 结构化日志(JSON 格式)
|
||||
- Docker 环境下输出到 stdout
|
||||
- 保留文件日志供宿主机查看(挂载 `./logs` 目录)
|
||||
|
||||
### 8.4 错误处理
|
||||
|
||||
- 统一异常处理中间件
|
||||
- 健康检查 `/health` 含数据库连通性验证
|
||||
- 关键操作结构化日志记录
|
||||
|
||||
### 8.5 代码质量
|
||||
|
||||
- `ruff` 替代 flake8 + black
|
||||
- `pyproject.toml` 统一管理依赖和工具配置
|
||||
- 全面 Type Hints
|
||||
|
||||
### 8.6 .gitignore
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 迁移策略
|
||||
|
||||
### 分步执行
|
||||
|
||||
1. 创建新项目结构骨架(FastAPI app + config)
|
||||
2. 数据库模型 + Alembic 迁移
|
||||
3. 迁移爬虫模块(异步改造)
|
||||
4. 迁移服务层(筛选、通知、Markdown)
|
||||
5. 迁移 WeChat 回调(Flask → FastAPI route)
|
||||
6. 添加 APScheduler 定时任务
|
||||
7. 编写测试
|
||||
8. Docker 化
|
||||
9. 清理旧文件
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- 保留 `dahuagov_announcements` 表结构不变
|
||||
- 企业微信回调 URL 路径保持不变 (`/api/v1/wechat/callback`)
|
||||
- 数据库迁移使用 Alembic,不丢失现有数据
|
||||
|
||||
---
|
||||
|
||||
## 10. 未包含的内容
|
||||
|
||||
- 不做多用户认证/授权(个人使用)
|
||||
- 不做 Celery 分布式任务(个人使用,APScheduler 足够)
|
||||
- 不做 Redis 缓存
|
||||
- 不做前端 UI
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 数据库迁移丢失数据 | Alembic 自动生成迁移脚本,先在测试环境验证 |
|
||||
| 异步爬虫被目标站限流 | 保留现有延迟/重试机制,httpx 支持同样的超时配置 |
|
||||
| 企业微信回调兼容性 | 回调路径不变,加解密逻辑完全复用现有代码 |
|
||||
| Docker 网络访问 10.10.10.14 | 确认 Docker 宿主机可访问该 IP,必要时用 `host.docker.internal` |
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,135 +0,0 @@
|
||||
# 广西政府采购网公告监控系统配置文件
|
||||
# 复制此文件为 config.yaml 并修改相应配置
|
||||
|
||||
# 调试模式
|
||||
debug: false
|
||||
|
||||
# 日志配置
|
||||
log_level: INFO
|
||||
log_file: logs/gx_gp_monitor.log
|
||||
log_max_size: 10485760 # 10MB
|
||||
log_backup_count: 5
|
||||
|
||||
# 爬虫配置
|
||||
crawler:
|
||||
base_url: "https://zfcg.gxzf.gov.cn"
|
||||
timeout: 30 # 请求超时时间(秒)
|
||||
max_retries: 3 # 最大重试次数
|
||||
retry_delay: 1.0 # 重试初始延迟
|
||||
max_retry_delay: 60.0 # 重试最大延迟
|
||||
backoff_factor: 2.0 # 退避因子
|
||||
user_agents: # User-Agent列表
|
||||
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
proxies: [] # 代理列表
|
||||
request_delay: 1.0 # 请求间延迟
|
||||
request_delay_max: 3.0 # 请求间最大延迟
|
||||
keyword: ["大化"] # 关键词筛选(支持多个关键词)
|
||||
start_date: "" # 开始日期 (YYYY-MM-DD)
|
||||
end_date: "" # 结束日期 (YYYY-MM-DD)
|
||||
max_pages: 10 # 最大页数
|
||||
page_size: 100 # 每页大小
|
||||
|
||||
# 数据库配置
|
||||
database:
|
||||
enabled: true # 是否启用数据库存储
|
||||
type: postgresql # 数据库类型
|
||||
host: "10.10.10.14" # 数据库主机
|
||||
port: 5432 # 数据库端口
|
||||
name: "gx-gp-notify" # 数据库名称
|
||||
user: "gx-gp-notify" # 数据库用户名
|
||||
password: "MA6RBX4F6Bd5DGmw" # 数据库密码
|
||||
pool_size: 5 # 连接池大小
|
||||
max_overflow: 10 # 最大连接数
|
||||
pool_timeout: 30 # 连接超时时间
|
||||
pool_recycle: 3600 # 连接回收时间(秒)
|
||||
data_retention_days: 90 # 数据保留天数
|
||||
auto_cleanup: true # 是否自动清理过期数据
|
||||
|
||||
# 企业微信通知配置
|
||||
wechat_app:
|
||||
enabled: true # 是否启用企业微信通知
|
||||
corp_id: "ww69e8e44636f47780" # 企业ID
|
||||
agent_id: "1000007" # 应用ID
|
||||
secret: "SmelCwKFoL0E9ATWFzr-w7gsfXBTN72lT1UqnNd0HpI" # 应用Secret
|
||||
token: "DmvL98cAF6x9CFtQZwqD2emGL8S7HxA" # Token
|
||||
encoding_aes_key: "yAc4OoSCP92YTefHXYfw27WeG9oF11W9d6nw6QYlU3D" # 消息加密Key
|
||||
port: 18001 # 服务端口
|
||||
host: "0.0.0.0" # 服务主机
|
||||
debug: false # 调试模式
|
||||
use_proxy: false # 是否使用代理API
|
||||
proxy_api_url: "https://api.v6ole.top" # 代理API地址
|
||||
|
||||
# Markdown输出配置
|
||||
markdown:
|
||||
enabled: true # 是否启用Markdown输出
|
||||
output_file: "onu.md" # 输出文件路径
|
||||
max_entries: 1000 # 最大条目数
|
||||
include_today_highlight: true # 是否突出显示今日公告
|
||||
template_file: "templates/announcement.md" # 模板文件
|
||||
|
||||
# 公告来源配置
|
||||
sources:
|
||||
ZcyAnnouncement1:
|
||||
category_id: 66485
|
||||
name: "采购公告"
|
||||
type: "purchase"
|
||||
ZcyAnnouncement2:
|
||||
category_id: 66485
|
||||
name: "结果公告"
|
||||
type: "result"
|
||||
ZcyAnnouncement3:
|
||||
category_id: 66485
|
||||
name: "合同公告"
|
||||
type: "contract"
|
||||
ZcyAnnouncement4:
|
||||
category_id: 66485
|
||||
name: "更正公告"
|
||||
type: "correction"
|
||||
ZcyAnnouncement5:
|
||||
category_id: 66485
|
||||
name: "招标文件预公示"
|
||||
type: "pre_announcement"
|
||||
ZcyAnnouncement6:
|
||||
category_id: 66485
|
||||
name: "单一来源公示"
|
||||
type: "single_source"
|
||||
ZcyAnnouncement7:
|
||||
category_id: 66485
|
||||
name: "电子卖场公示"
|
||||
type: "electronic_market"
|
||||
ZcyAnnouncement10:
|
||||
category_id: 66485
|
||||
name: "履约验收公示"
|
||||
type: "acceptance"
|
||||
ZcyAnnouncement11:
|
||||
category_id: 66485
|
||||
name: "工程类公告"
|
||||
type: "engineering"
|
||||
ZcyAnnouncement20:
|
||||
category_id: 66485
|
||||
name: "框架协议征集公告"
|
||||
type: "framework_agreement"
|
||||
ZcyAnnouncement21:
|
||||
category_id: 66485
|
||||
name: "框架协议入围结果公告"
|
||||
type: "framework_result"
|
||||
ZcyAnnouncement23:
|
||||
category_id: 66485
|
||||
name: "框架协议成交结果汇总公告"
|
||||
type: "framework_summary"
|
||||
"61-266648":
|
||||
category_id: 66485
|
||||
name: "采购意向公开"
|
||||
type: "intention"
|
||||
|
||||
# 调度配置已移除 - 如需定时任务功能,请重新添加
|
||||
|
||||
# 监控配置
|
||||
monitoring:
|
||||
enabled: true # 是否启用监控
|
||||
health_check_interval: 300 # 健康检查间隔(秒)
|
||||
alert_on_failure: true # 失败时是否告警
|
||||
max_consecutive_failures: 3 # 最大连续失败次数
|
||||
metrics_enabled: true # 是否启用指标收集
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,493 +0,0 @@
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 512
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: GwLOHlMWE3qBsHJ3DegXZk5RGOb0hEg2g9SbYIvxkhynxr/43t...
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: vlN4cpR79+nVcy2Cvf22D4nsbOxqtyYFlVO3nvPdIz2NLVZTCr...
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 052516f0e87dd3793a1035dccb0f3b3d9fd806de
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 060aff2e6666d1df9c957ebb61d214664c64e72f
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 052516f0e87dd3793a1035dccb0f3b3d9fd806de
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 060aff2e6666d1df9c957ebb61d214664c64e72f
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=河池分行大化瑶族自治县中医医院结算系
|
||||
统项目..., user=NongGuangGuo
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 河池分行大化瑶族自治县中医医院结算系
|
||||
统项目, user: NongGuangGuo
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 关键词搜索: ['河池分行大化瑶族自治县中医医院结算系', '统项目']
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=crawl_now, user=NongGuangGuo
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: crawl_now, user: NongGuangGuo
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 触发立即搜索
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - === 广西政府采购网公告监控系统启动 ===
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 版本: 1.0.0
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 配置文件: 默认配置
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - === 广西政府采购网公告监控系统启动 ===
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 版本: 1.0.0
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 配置文件: 默认配置
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.core.database - INFO - 开始初始化数据库表结构
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.core.database - INFO - 数据库表结构初始化完成
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.storage.postgresql - INFO - 存储初始化完成
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 应用初始化完成
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: False)
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:08:19 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.core.reliability - INFO - 健康检查通过
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.core.database - INFO - 开始初始化数据库表结构
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.core.database - INFO - 数据库表结构初始化完成
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.storage.postgresql - INFO - 存储初始化完成
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 应用初始化完成
|
||||
2026-05-06 15:08:19 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: True)
|
||||
2026-05-06 15:08:20 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:08:20 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 0.44秒
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 开始爬取 结果公告
|
||||
2026-05-06 15:08:20 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.40秒
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 开始爬取 合同公告
|
||||
2026-05-06 15:08:21 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.46秒
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 开始爬取 更正公告
|
||||
2026-05-06 15:08:21 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
|
||||
2026-05-06 15:08:21 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
|
||||
2026-05-06 15:08:22 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:22 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:22 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
|
||||
2026-05-06 15:08:22 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:22 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.50秒
|
||||
2026-05-06 15:08:22 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
|
||||
2026-05-06 15:08:22 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:22 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.27秒
|
||||
2026-05-06 15:08:22 - gx_gp_monitor - INFO - 开始爬取 工程类公告
|
||||
2026-05-06 15:08:23 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:23 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.30秒
|
||||
2026-05-06 15:08:23 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
|
||||
2026-05-06 15:08:23 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:23 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.27秒
|
||||
2026-05-06 15:08:23 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
|
||||
2026-05-06 15:08:23 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:23 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.26秒
|
||||
2026-05-06 15:08:23 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:24 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.27秒
|
||||
2026-05-06 15:08:24 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:24 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.29秒
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.main - INFO - 搜索到 1300 条原始公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 开始按来源保存 1300 条公告,每个来源最多保留 100 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement1 保存了 1 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement2 保存了 1 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement3 保存了 1 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement4 保存了 1 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement5 保存了 1 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement6 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement7 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement10 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement11 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement20 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement21 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement23 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 来源 61-266648 保存了 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.postgresql - INFO - 按来源保存完成,总计保存 5 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.main - INFO - 保存自动爬取公告完成:共保存 5 条,按来源统计: {'ZcyAnnouncement1': 1, 'ZcyAnnouncement2': 1, 'ZcyAnnouncement3': 1, 'ZcyAnnouncement4': 1, 'ZcyAnnouncement5': 1, 'ZcyAnnouncement6': 0, 'ZcyAnnouncement7': 0, 'ZcyAnnouncement10': 0, 'ZcyAnnouncement11': 0, 'ZcyAnnouncement20': 0, 'ZcyAnnouncement21': 0, 'ZcyAnnouncement23': 0, '61-266648': 0}
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.main - INFO - 筛选出 1300 条新公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.filters.filters - INFO - 来源筛选: 1300 -> 1300 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.filters.filters - INFO - 去重筛选: 移除了 1300 条重复公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.filters.filters - INFO - 关键词筛选: 0 -> 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.filters.filters - INFO - 筛选完成: 总数 1300 -> 筛选后 0 (关键词: 0, 日期: 0, 去重: 1300, 来源: 0)
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.main - INFO - 筛选后剩余 0 条公告
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.storage.md_generator - INFO - Markdown文件已保存到: onu.md (共 0 条公告)
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.main - INFO - 爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': True, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 1300, 'date_filtered': 0, 'duplicate_filtered': 1300, 'source_filtered': 0}}
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: /GWEdhQRyz77Ot0gML2ySdkbya+0yS/l2Zun0R3RRbsUsiZ/5/...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: a3f07c703a094e51e6f65f10e1207347ba52e516
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: a3f07c703a094e51e6f65f10e1207347ba52e516
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_search, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_search, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 触发关键词搜索菜单
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Zh2CaGZnzxNyfY2UAmovYgB312yS33AE9njwfsmdnR4Zdz6dpM...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 2b12a7b22e5b1ce6051b25e1239eb3f89e168f89
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 2b12a7b22e5b1ce6051b25e1239eb3f89e168f89
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: /GWEdhQRyz77Ot0gML2ySdkbya+0yS/l2Zun0R3RRbsUsiZ/5/...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: a3f07c703a094e51e6f65f10e1207347ba52e516
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: a3f07c703a094e51e6f65f10e1207347ba52e516
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_search, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_search, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 触发关键词搜索菜单
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: BShc5Ko31VKzOh2A5AV7Id4WWX6+b+i013Kb+j4gOeFD4QpMz/...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: bcacf78d1048a9a06df24a596f173cd483feff96
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: bcacf78d1048a9a06df24a596f173cd483feff96
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Zh2CaGZnzxNyfY2UAmovYgB312yS33AE9njwfsmdnR4Zdz6dpM...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 2b12a7b22e5b1ce6051b25e1239eb3f89e168f89
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 2b12a7b22e5b1ce6051b25e1239eb3f89e168f89
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Hj5eJcf9InnhKnkJxjUdwjrfJLtEGw9pSsTXEZUemhGwdaseGY...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: efc35b9b2fb9c388203fd0d92725e4c52e7971bd
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: efc35b9b2fb9c388203fd0d92725e4c52e7971bd
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: /GWEdhQRyz77Ot0gML2ySdkbya+0yS/l2Zun0R3RRbsUsiZ/5/...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: a3f07c703a094e51e6f65f10e1207347ba52e516
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: a3f07c703a094e51e6f65f10e1207347ba52e516
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_search, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_search, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 触发关键词搜索菜单
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: BShc5Ko31VKzOh2A5AV7Id4WWX6+b+i013Kb+j4gOeFD4QpMz/...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: bcacf78d1048a9a06df24a596f173cd483feff96
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: bcacf78d1048a9a06df24a596f173cd483feff96
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Zh2CaGZnzxNyfY2UAmovYgB312yS33AE9njwfsmdnR4Zdz6dpM...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 2b12a7b22e5b1ce6051b25e1239eb3f89e168f89
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 2b12a7b22e5b1ce6051b25e1239eb3f89e168f89
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Hj5eJcf9InnhKnkJxjUdwjrfJLtEGw9pSsTXEZUemhGwdaseGY...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: efc35b9b2fb9c388203fd0d92725e4c52e7971bd
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: efc35b9b2fb9c388203fd0d92725e4c52e7971bd
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: s2AORSQopkHXxA09QhYM2xaxUQiwXq6SbJmEVtAdVgBsKdaTyk...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 09b95c92d81b3fdf2e2ce2d39f26f3fabb702ca2
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 09b95c92d81b3fdf2e2ce2d39f26f3fabb702ca2
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=help_guide, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: help_guide, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求使用说明
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: BShc5Ko31VKzOh2A5AV7Id4WWX6+b+i013Kb+j4gOeFD4QpMz/...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: bcacf78d1048a9a06df24a596f173cd483feff96
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: bcacf78d1048a9a06df24a596f173cd483feff96
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: koSh3mf1jROPiRIjbk0r2Wxz6G45YqfC7e6FEMFpko9WHl2TaA...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: b1009660d3a9d1248e4f4ae0380f311e15a31b35
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: b1009660d3a9d1248e4f4ae0380f311e15a31b35
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Hj5eJcf9InnhKnkJxjUdwjrfJLtEGw9pSsTXEZUemhGwdaseGY...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: efc35b9b2fb9c388203fd0d92725e4c52e7971bd
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: efc35b9b2fb9c388203fd0d92725e4c52e7971bd
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: s2AORSQopkHXxA09QhYM2xaxUQiwXq6SbJmEVtAdVgBsKdaTyk...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 09b95c92d81b3fdf2e2ce2d39f26f3fabb702ca2
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 09b95c92d81b3fdf2e2ce2d39f26f3fabb702ca2
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=help_guide, user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: help_guide, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求使用说明
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Wf8nBEZm2h74BAUuJO4v23ZodRVoaehzIkrZmu5znNTFx6ZviG...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 1374c68c6060fedb594170f77ac661f74115216a
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 1374c68c6060fedb594170f77ac661f74115216a
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: gpwcqJRahQ4lkz8z+9dRmAKMTp6cZTj7TFaoc7dXjJ+gpiTKee...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 2f83d678f4cf2d420d13889341727e94da5f7e20
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 2f83d678f4cf2d420d13889341727e94da5f7e20
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=你好..., user=WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 你好, user: WeiJueSen
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 关键词搜索: ['你好']
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: True)
|
||||
2026-05-06 15:08:24 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:08:24 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:08:25 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:25 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 0.45秒
|
||||
2026-05-06 15:08:25 - gx_gp_monitor - INFO - 开始爬取 结果公告
|
||||
2026-05-06 15:08:25 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:25 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.36秒
|
||||
2026-05-06 15:08:25 - gx_gp_monitor - INFO - 开始爬取 合同公告
|
||||
2026-05-06 15:08:26 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.30秒
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 开始爬取 更正公告
|
||||
2026-05-06 15:08:26 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
|
||||
2026-05-06 15:08:26 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
|
||||
2026-05-06 15:08:26 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.30秒
|
||||
2026-05-06 15:08:26 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
|
||||
2026-05-06 15:08:27 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:27 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.48秒
|
||||
2026-05-06 15:08:27 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
|
||||
2026-05-06 15:08:27 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:27 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.29秒
|
||||
2026-05-06 15:08:27 - gx_gp_monitor - INFO - 开始爬取 工程类公告
|
||||
2026-05-06 15:08:28 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:28 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.40秒
|
||||
2026-05-06 15:08:28 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
|
||||
2026-05-06 15:08:28 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:28 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:28 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
|
||||
2026-05-06 15:08:28 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:28 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.27秒
|
||||
2026-05-06 15:08:28 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:29 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.29秒
|
||||
2026-05-06 15:08:29 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:29 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.main - INFO - 搜索到 1300 条原始公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 开始按来源保存 1300 条手动搜索公告到专用表,每个来源最多保留 100 条
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement1 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement2 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement3 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement4 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement5 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement6 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement7 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement10 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement11 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement20 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement21 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement23 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 来源 61-266648 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.storage.postgresql - INFO - 按来源保存手动搜索公告完成,总计保存 13 条公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.main - INFO - 保存手动搜索公告完成:共保存 13 条,按来源统计: {'ZcyAnnouncement1': 1, 'ZcyAnnouncement2': 1, 'ZcyAnnouncement3': 1, 'ZcyAnnouncement4': 1, 'ZcyAnnouncement5': 1, 'ZcyAnnouncement6': 1, 'ZcyAnnouncement7': 1, 'ZcyAnnouncement10': 1, 'ZcyAnnouncement11': 1, 'ZcyAnnouncement20': 1, 'ZcyAnnouncement21': 1, 'ZcyAnnouncement23': 1, '61-266648': 1}
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.filters.filters - INFO - 日期筛选: 1300 -> 352 条公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.filters.filters - INFO - 关键词筛选: 352 -> 0 条公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.filters.filters - INFO - 来源筛选: 0 -> 0 条公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.main - INFO - 筛选后剩余 0 条公告
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.main - INFO - 手动爬取模式:跳过筛选后公告的数据库保存
|
||||
2026-05-06 15:08:29 - gx_gp_monitor.main - INFO - 爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': False, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 352, 'date_filtered': 948, 'duplicate_filtered': 0, 'source_filtered': 0}, 'filtered_announcements': []}
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: rMwd7/7jivhj3K171JP9cTR0n/vSdK/XxP5FWC4NirBXOkApNN...
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: aff09303a74cb9b91b58bda7f544420d89e99342
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: aff09303a74cb9b91b58bda7f544420d89e99342
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:39 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: 9BiCuWCpV4Por0LpC3Q2d+iJL/AY9a8gv724JzeANrK2O/S/9L...
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 3b373232e89cde03fef86f3efae1d3848558428c
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 3b373232e89cde03fef86f3efae1d3848558428c
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:08:46 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:08:50 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:50 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 30.48秒
|
||||
2026-05-06 15:08:50 - gx_gp_monitor - INFO - 开始爬取 结果公告
|
||||
2026-05-06 15:08:50 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:50 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:50 - gx_gp_monitor - INFO - 开始爬取 合同公告
|
||||
2026-05-06 15:08:51 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:51 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.29秒
|
||||
2026-05-06 15:08:51 - gx_gp_monitor - INFO - 开始爬取 更正公告
|
||||
2026-05-06 15:08:51 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:51 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.34秒
|
||||
2026-05-06 15:08:51 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
|
||||
2026-05-06 15:08:51 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:51 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:51 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
|
||||
2026-05-06 15:08:52 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:52 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.28秒
|
||||
2026-05-06 15:08:52 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
|
||||
2026-05-06 15:08:52 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:52 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.46秒
|
||||
2026-05-06 15:08:52 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
|
||||
2026-05-06 15:08:52 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:52 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.40秒
|
||||
2026-05-06 15:08:52 - gx_gp_monitor - INFO - 开始爬取 工程类公告
|
||||
2026-05-06 15:08:53 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:53 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.30秒
|
||||
2026-05-06 15:08:53 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
|
||||
2026-05-06 15:08:53 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:53 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.26秒
|
||||
2026-05-06 15:08:53 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
|
||||
2026-05-06 15:08:53 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:53 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.28秒
|
||||
2026-05-06 15:08:53 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:54 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:08:54 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:54 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.30秒
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.main - INFO - 搜索到 1300 条原始公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 开始按来源保存 1300 条手动搜索公告到专用表,每个来源最多保留 100 条
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement1 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement2 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement3 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement4 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement5 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement6 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement7 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement10 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement11 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement20 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement21 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement23 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.core.database - INFO - 批量保存公告到manual_announcements完成,影响行数: 1
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 来源 61-266648 保存了 1 条手动搜索公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.storage.postgresql - INFO - 按来源保存手动搜索公告完成,总计保存 13 条公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.main - INFO - 保存手动搜索公告完成:共保存 13 条,按来源统计: {'ZcyAnnouncement1': 1, 'ZcyAnnouncement2': 1, 'ZcyAnnouncement3': 1, 'ZcyAnnouncement4': 1, 'ZcyAnnouncement5': 1, 'ZcyAnnouncement6': 1, 'ZcyAnnouncement7': 1, 'ZcyAnnouncement10': 1, 'ZcyAnnouncement11': 1, 'ZcyAnnouncement20': 1, 'ZcyAnnouncement21': 1, 'ZcyAnnouncement23': 1, '61-266648': 1}
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.filters.filters - INFO - 日期筛选: 1300 -> 352 条公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.filters.filters - INFO - 关键词筛选: 352 -> 0 条公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.filters.filters - INFO - 来源筛选: 0 -> 0 条公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.main - INFO - 筛选后剩余 0 条公告
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.main - INFO - 手动爬取模式:跳过筛选后公告的数据库保存
|
||||
2026-05-06 15:08:54 - gx_gp_monitor.main - INFO - 爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': False, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 352, 'date_filtered': 948, 'duplicate_filtered': 0, 'source_filtered': 0}, 'filtered_announcements': []}
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: thc6lzWswZ3ZylEM+A5xlLi2CKg5D6FMa1JFGzAuW6/IrfJMqq...
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 16e97f27eb0f7307ebb9b87c5a072b613b2c8a18
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 16e97f27eb0f7307ebb9b87c5a072b613b2c8a18
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=help_guide, user=NongGuangGuo
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: help_guide, user: NongGuangGuo
|
||||
2026-05-06 15:09:28 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求使用说明
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: 5ju3K87vTeR4WaPsI8IqATUO1g30XklsBfeGNDSzcH09aZgr3x...
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 5c49ef855bd9a5763992d62e734aef75666a4a2c
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 5c49ef855bd9a5763992d62e734aef75666a4a2c
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=today_stats, user=WeiJueSen
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: today_stats, user: WeiJueSen
|
||||
2026-05-06 15:09:33 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求今日统计
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 384
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: Cu+8S6elykWeq3afFdD/lbMtOni21mETVdpRjEn+AHtiYRlBYV...
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 21012d5bc0f6d7e71328b0ec2ccb62b938898f89
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 21012d5bc0f6d7e71328b0ec2ccb62b938898f89
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=subscribe, event_key=None, user=LuLiuCui
|
||||
2026-05-09 10:31:46 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: subscribe, key: None, user: LuLiuCui
|
||||
@@ -1,139 +0,0 @@
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: vlN4cpR79+nVcy2Cvf22D4nsbOxqtyYFlVO3nvPdIz2NLVZTCr...
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 060aff2e6666d1df9c957ebb61d214664c64e72f
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 060aff2e6666d1df9c957ebb61d214664c64e72f
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=crawl_now, user=NongGuangGuo
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: crawl_now, user: NongGuangGuo
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 触发立即搜索
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: False)
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.core.reliability - INFO - 健康检查通过
|
||||
2026-05-06 15:07:43 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:07:43 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:07:44 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:44 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 0.53秒
|
||||
2026-05-06 15:07:44 - gx_gp_monitor - INFO - 开始爬取 结果公告
|
||||
2026-05-06 15:07:44 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:44 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.43秒
|
||||
2026-05-06 15:07:44 - gx_gp_monitor - INFO - 开始爬取 合同公告
|
||||
2026-05-06 15:07:45 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:45 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.29秒
|
||||
2026-05-06 15:07:45 - gx_gp_monitor - INFO - 开始爬取 更正公告
|
||||
2026-05-06 15:07:45 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:45 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.38秒
|
||||
2026-05-06 15:07:45 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
|
||||
2026-05-06 15:07:45 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:45 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:07:45 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:46 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.34秒
|
||||
2026-05-06 15:07:46 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:46 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.51秒
|
||||
2026-05-06 15:07:46 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
|
||||
2026-05-06 15:07:47 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.30秒
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 开始爬取 工程类公告
|
||||
2026-05-06 15:07:47 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.27秒
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
|
||||
2026-05-06 15:07:47 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.35秒
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
|
||||
2026-05-06 15:07:47 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.32秒
|
||||
2026-05-06 15:07:47 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:48 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.35秒
|
||||
2026-05-06 15:07:48 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:07:48 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.33秒
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.main - INFO - 搜索到 1300 条原始公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 开始按来源保存 1300 条公告,每个来源最多保留 100 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 数据库连接错误: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 批量保存公告失败: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement1 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 数据库连接错误: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 批量保存公告失败: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement2 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 数据库连接错误: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 批量保存公告失败: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement3 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 数据库连接错误: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 批量保存公告失败: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement4 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 数据库连接错误: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - ERROR - 批量保存公告失败: connection already closed
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement5 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement6 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement7 保存了 1 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement10 保存了 1 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement11 保存了 1 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement20 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement21 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 0 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 ZcyAnnouncement23 保存了 0 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.core.database - INFO - 批量保存公告完成,成功保存 1 条
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 来源 61-266648 保存了 1 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.storage.postgresql - INFO - 按来源保存完成,总计保存 4 条公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.main - INFO - 保存自动爬取公告完成:共保存 4 条,按来源统计: {'ZcyAnnouncement1': 0, 'ZcyAnnouncement2': 0, 'ZcyAnnouncement3': 0, 'ZcyAnnouncement4': 0, 'ZcyAnnouncement5': 0, 'ZcyAnnouncement6': 0, 'ZcyAnnouncement7': 1, 'ZcyAnnouncement10': 1, 'ZcyAnnouncement11': 1, 'ZcyAnnouncement20': 0, 'ZcyAnnouncement21': 0, 'ZcyAnnouncement23': 0, '61-266648': 1}
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.main - INFO - 筛选出 1300 条新公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.filters.filters - INFO - 来源筛选: 1300 -> 1300 条公告
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 512
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: GwLOHlMWE3qBsHJ3DegXZk5RGOb0hEg2g9SbYIvxkhynxr/43t...
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 052516f0e87dd3793a1035dccb0f3b3d9fd806de
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 052516f0e87dd3793a1035dccb0f3b3d9fd806de
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=河池分行大化瑶族自治县中医医院结算系
|
||||
统项目..., user=NongGuangGuo
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 河池分行大化瑶族自治县中医医院结算系
|
||||
统项目, user: NongGuangGuo
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 关键词搜索: ['河池分行大化瑶族自治县中医医院结算系', '统项目']
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: True)
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:07:49 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.filters.filters - INFO - 去重筛选: 移除了 803 条重复公告
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.filters.filters - INFO - 关键词筛选: 497 -> 0 条公告
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.filters.filters - INFO - 筛选完成: 总数 1300 -> 筛选后 0 (关键词: 497, 日期: 0, 去重: 803, 来源: 0)
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.main - INFO - 筛选后剩余 0 条公告
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.storage.md_generator - INFO - Markdown文件已保存到: onu.md (共 0 条公告)
|
||||
2026-05-06 15:07:49 - gx_gp_monitor.main - INFO - 爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': True, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 1797, 'date_filtered': 0, 'duplicate_filtered': 803, 'source_filtered': 0}}
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: p4+cMpoxGV5kJJ2lLlM6u6/HHN4OJMSHH/wxWwSxKkZGGxajZn...
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: fc48810bdc9c0216f44d1adde162007b4cca0790
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: fc48810bdc9c0216f44d1adde162007b4cca0790
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=crawl_now, user=NongGuangGuo
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: crawl_now, user: NongGuangGuo
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 触发立即搜索
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: False)
|
||||
2026-05-06 15:07:51 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:07:51 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:08:20 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 30.54秒
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 开始爬取 结果公告
|
||||
2026-05-06 15:08:20 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.34秒
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 开始爬取 合同公告
|
||||
2026-05-06 15:08:20 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:08:20 - gx_gp_monitor - INFO - 开始爬取 更正公告
|
||||
2026-05-06 15:08:21 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.33秒
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
|
||||
2026-05-06 15:08:21 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:08:21 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
|
||||
@@ -1,48 +0,0 @@
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: QMt5ylkt6tq8Vl8TdJuv5/WIdXPum6+KEhz/iY06ZJZ6cc6oAi...
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: d9502bdfb3f5fe20997c0668e5f7af42d6444726
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: d9502bdfb3f5fe20997c0668e5f7af42d6444726
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=keyword_manage, user=NongGuangGuo
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: keyword_manage, user: NongGuangGuo
|
||||
2026-05-06 15:07:40 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 请求关键词管理
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: p4+cMpoxGV5kJJ2lLlM6u6/HHN4OJMSHH/wxWwSxKkZGGxajZn...
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: fc48810bdc9c0216f44d1adde162007b4cca0790
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: fc48810bdc9c0216f44d1adde162007b4cca0790
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=crawl_now, user=NongGuangGuo
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: crawl_now, user: NongGuangGuo
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 触发立即搜索
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: False)
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.core.reliability - INFO - 健康检查通过
|
||||
2026-05-06 15:07:46 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:07:46 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: vlN4cpR79+nVcy2Cvf22D4nsbOxqtyYFlVO3nvPdIz2NLVZTCr...
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 060aff2e6666d1df9c957ebb61d214664c64e72f
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 060aff2e6666d1df9c957ebb61d214664c64e72f
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名...
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=event
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.callback_server - INFO - 处理事件消息: event=click, event_key=crawl_now, user=NongGuangGuo
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.message_handler - INFO - 处理事件: click, key: crawl_now, user: NongGuangGuo
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.wechat.message_handler - INFO - 用户 NongGuangGuo 触发立即搜索
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.main - INFO - 开始执行搜索任务 (手动搜索: False)
|
||||
2026-05-06 15:07:48 - gx_gp_monitor.crawler.spider - INFO - 开始爬取 13 个公告来源
|
||||
2026-05-06 15:07:48 - gx_gp_monitor - INFO - 开始爬取 采购公告
|
||||
2026-05-06 15:08:17 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:17 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 30.47秒
|
||||
2026-05-06 15:08:17 - gx_gp_monitor - INFO - 开始爬取 结果公告
|
||||
2026-05-06 15:08:17 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:17 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.34秒
|
||||
2026-05-06 15:08:17 - gx_gp_monitor - INFO - 开始爬取 合同公告
|
||||
2026-05-06 15:08:17 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:17 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.31秒
|
||||
2026-05-06 15:08:17 - gx_gp_monitor - INFO - 开始爬取 更正公告
|
||||
2026-05-06 15:08:18 - gx_gp_monitor.crawler.parsers - INFO - 成功解析 100/100 条公告记录
|
||||
2026-05-06 15:08:18 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.33秒
|
||||
2026-05-06 15:08:18 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
|
||||
File diff suppressed because it is too large
Load Diff
-107029
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
📋 关键词搜索: `未知` - 总公告数: `0`
|
||||
|
||||
**更新时间: 2026-05-06 15:08:57**
|
||||
|
||||
|
||||
## 无匹配公告
|
||||
|
||||
在指定时间范围内没有找到符合条件的公告。
|
||||
@@ -0,0 +1,44 @@
|
||||
[project]
|
||||
name = "gx-gp-notify"
|
||||
version = "2.0.0"
|
||||
description = "广西政府采购网公告监控系统"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"sqlalchemy[asyncio]>=2.0.30",
|
||||
"asyncpg>=0.29.0",
|
||||
"alembic>=1.13.0",
|
||||
"httpx>=0.27.0",
|
||||
"apscheduler>=3.10.0",
|
||||
"pydantic-settings>=2.3.0",
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"lxml>=5.2.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"python-dateutil>=2.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.2.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
"ruff>=0.4.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"app/wechat/*.py" = ["N802", "N803", "N806"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,13 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_announcement_data():
|
||||
return {
|
||||
"title": "测试公告标题",
|
||||
"publish_date": "2026-05-09",
|
||||
"purchase_name": "测试单位",
|
||||
"content_url": "https://example.com/detail/123",
|
||||
"source_code": "ZcyAnnouncement1",
|
||||
"source_name": "采购公告",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig, PipelineResult
|
||||
|
||||
|
||||
class FakeSpider(BaseSpider):
|
||||
name = "test_spider"
|
||||
source_code = "test_source"
|
||||
source_name = "测试来源"
|
||||
|
||||
async def crawl(self) -> CrawlResult:
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
total_count=5,
|
||||
new_count=3,
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
|
||||
def get_pipeline_config(self) -> PipelineConfig:
|
||||
return PipelineConfig(
|
||||
filter_enabled=True,
|
||||
keywords=["测试"],
|
||||
dedup_enabled=True,
|
||||
notify_mode="filtered",
|
||||
mark_sent=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_spider_crawl():
|
||||
spider = FakeSpider()
|
||||
result = await spider.crawl()
|
||||
assert result.source_code == "test_source"
|
||||
assert result.total_count == 5
|
||||
assert result.new_count == 3
|
||||
assert result.success is True
|
||||
|
||||
|
||||
def test_pipeline_config_defaults():
|
||||
config = PipelineConfig()
|
||||
assert config.filter_enabled is True
|
||||
assert config.keywords == []
|
||||
assert config.dedup_enabled is True
|
||||
assert config.notify_mode == "filtered"
|
||||
assert config.mark_sent is False
|
||||
|
||||
|
||||
def test_pipeline_result_defaults():
|
||||
result = PipelineResult()
|
||||
assert result.stored == 0
|
||||
assert result.filtered == 0
|
||||
assert result.notified == 0
|
||||
assert result.markdown_generated is False
|
||||
|
||||
|
||||
def test_generate_content_hash():
|
||||
h = BaseSpider.generate_content_hash(
|
||||
"title", "2026-05-09", "unit", "https://x.com", "source"
|
||||
)
|
||||
assert len(h) == 64
|
||||
assert all(c in "0123456789abcdef" for c in h)
|
||||
|
||||
|
||||
def test_generate_content_hash_deterministic():
|
||||
h1 = BaseSpider.generate_content_hash("t", "d", "p", "u", "s")
|
||||
h2 = BaseSpider.generate_content_hash("t", "d", "p", "u", "s")
|
||||
assert h1 == h2
|
||||
|
||||
|
||||
def test_crawl_result_failure():
|
||||
result = CrawlResult(
|
||||
source_code="s", source_name="n",
|
||||
error_message="连接超时"
|
||||
)
|
||||
assert result.success is False
|
||||
@@ -0,0 +1,64 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crawler.base import PipelineConfig
|
||||
from app.crawler.dahuagov_spider import DahuagovSpider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dahuagov_spider_attributes():
|
||||
spider = DahuagovSpider()
|
||||
assert spider.name == "dahuagov"
|
||||
assert spider.source_code == "dahuagov"
|
||||
assert spider.source_name == "大化县政府网采购公告"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dahuagov_spider_pipeline_config():
|
||||
spider = DahuagovSpider()
|
||||
config = spider.get_pipeline_config()
|
||||
assert isinstance(config, PipelineConfig)
|
||||
assert config.filter_enabled is False
|
||||
assert config.notify_mode == "all"
|
||||
assert config.mark_sent is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dahuagov_spider_crawl():
|
||||
spider = DahuagovSpider()
|
||||
|
||||
html = """
|
||||
<html><body>
|
||||
<ul class="more-list">
|
||||
<li><span>2026-05-08</span>
|
||||
<a href="./detail/1.html" title="公告1">公告1</a></li>
|
||||
<li><span>2026-05-07</span>
|
||||
<a href="./detail/2.html" title="公告2">公告2</a></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = html
|
||||
|
||||
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)):
|
||||
result = await spider.crawl()
|
||||
assert result.total_count == 2
|
||||
assert len(result.announcements) == 2
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dahuagov_spider_crawl_empty():
|
||||
spider = DahuagovSpider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = "<html><body></body></html>"
|
||||
|
||||
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)):
|
||||
result = await spider.crawl()
|
||||
assert result.total_count == 0
|
||||
assert result.success is True
|
||||
@@ -0,0 +1,73 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crawler.base import PipelineConfig
|
||||
from app.crawler.gxgp_spider import GXGPSpider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gxgp_spider_attributes():
|
||||
spider = GXGPSpider()
|
||||
assert spider.name == "gxgp"
|
||||
assert spider.source_code == "gxgp"
|
||||
assert spider.source_name == "广西政府采购网"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gxgp_spider_pipeline_config():
|
||||
spider = GXGPSpider()
|
||||
config = spider.get_pipeline_config()
|
||||
assert isinstance(config, PipelineConfig)
|
||||
assert config.filter_enabled is True
|
||||
assert config.notify_mode == "filtered"
|
||||
assert config.mark_sent is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gxgp_spider_crawl_empty():
|
||||
spider = GXGPSpider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"result": {"data": {"data": [], "total": 0, "pageNo": 1, "pageSize": 100,
|
||||
"pages": 0, "empty": True, "hasNext": False, "hasPrevious": False}},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_response)):
|
||||
result = await spider.crawl(sources=["ZcyAnnouncement1"])
|
||||
assert result.total_count == 0
|
||||
assert len(result.announcements) == 0
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gxgp_spider_crawl_with_data():
|
||||
spider = GXGPSpider()
|
||||
|
||||
mock_data = {
|
||||
"success": True,
|
||||
"result": {
|
||||
"data": {
|
||||
"data": [{
|
||||
"title": "测试采购公告",
|
||||
"publishDate": 1746720000000,
|
||||
"purchaseName": "测试单位",
|
||||
"articleId": 12345,
|
||||
}],
|
||||
"total": 1, "pageNo": 1, "pageSize": 100,
|
||||
"pages": 1, "empty": False, "hasNext": False, "hasPrevious": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_data
|
||||
|
||||
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_response)):
|
||||
result = await spider.crawl(sources=["ZcyAnnouncement1"])
|
||||
assert result.total_count >= 0
|
||||
assert result.success is True
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.crawler.parsers import (
|
||||
extract_pagination,
|
||||
parse_dahuagov_html,
|
||||
parse_gxgp_api_response,
|
||||
)
|
||||
|
||||
|
||||
def make_api_response(records_data):
|
||||
return {
|
||||
"success": True,
|
||||
"result": {
|
||||
"data": {
|
||||
"data": records_data,
|
||||
"total": len(records_data),
|
||||
"pageNo": 1,
|
||||
"pageSize": 100,
|
||||
"pages": 1,
|
||||
"empty": len(records_data) == 0,
|
||||
"hasNext": False,
|
||||
"hasPrevious": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_gxgp_single_record():
|
||||
response = make_api_response([
|
||||
{
|
||||
"title": "测试采购公告",
|
||||
"publishDate": 1746720000000,
|
||||
"purchaseName": "测试采购单位",
|
||||
"articleId": 12345,
|
||||
}
|
||||
])
|
||||
crawled_at = datetime(2026, 5, 9, 10, 0, 0)
|
||||
results = parse_gxgp_api_response(
|
||||
response,
|
||||
source_code="ZcyAnnouncement1",
|
||||
source_name="采购公告",
|
||||
crawled_at=crawled_at,
|
||||
category_id=66485,
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "测试采购公告"
|
||||
assert results[0]["source_code"] == "ZcyAnnouncement1"
|
||||
assert "content_hash" in results[0]
|
||||
|
||||
|
||||
def test_parse_gxgp_empty_response():
|
||||
response = make_api_response([])
|
||||
crawled_at = datetime(2026, 5, 9, 10, 0, 0)
|
||||
results = parse_gxgp_api_response(
|
||||
response, "ZcyAnnouncement1", "采购公告", crawled_at, 66485
|
||||
)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_parse_gxgp_missing_title():
|
||||
response = make_api_response([
|
||||
{"title": "", "publishDate": 1746720000000, "purchaseName": "x", "articleId": 1}
|
||||
])
|
||||
crawled_at = datetime(2026, 5, 9, 10, 0, 0)
|
||||
results = parse_gxgp_api_response(
|
||||
response, "ZcyAnnouncement1", "采购公告", crawled_at, 66485
|
||||
)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_extract_pagination():
|
||||
response = make_api_response([])
|
||||
pagination = extract_pagination(response)
|
||||
assert pagination["total"] == 0
|
||||
assert pagination["page_no"] == 1
|
||||
assert pagination["has_next"] is False
|
||||
|
||||
|
||||
def test_parse_dahuagov_html():
|
||||
html = """
|
||||
<html><body>
|
||||
<ul class="more-list">
|
||||
<li>
|
||||
<span>2026-05-08</span>
|
||||
<a href="./detail/123.html" title="大化县某项目采购公告">大化县某项目采购公告</a>
|
||||
</li>
|
||||
<li>
|
||||
<span>2026-05-07</span>
|
||||
<a href="./detail/124.html" title="大化县另一采购公告">大化县另一采购公告</a>
|
||||
</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
"""
|
||||
crawled_at = datetime(2026, 5, 9, 10, 0, 0)
|
||||
results = parse_dahuagov_html(html, crawled_at)
|
||||
assert len(results) == 2
|
||||
assert results[0]["title"] == "大化县某项目采购公告"
|
||||
assert results[0]["source_code"] == "dahuagov"
|
||||
assert results[0]["source_name"] == "大化县政府网采购公告"
|
||||
assert results[0]["purchase_name"] == "大化瑶族自治县"
|
||||
|
||||
|
||||
def test_parse_dahuagov_html_no_list():
|
||||
html = "<html><body></body></html>"
|
||||
crawled_at = datetime(2026, 5, 9, 10, 0, 0)
|
||||
results = parse_dahuagov_html(html, crawled_at)
|
||||
assert len(results) == 0
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig
|
||||
from app.services.crawl_service import CrawlService
|
||||
|
||||
|
||||
class MockGXGPSpider(BaseSpider):
|
||||
name = "mock_gxgp"
|
||||
source_code = "mock_gxgp"
|
||||
source_name = "Mock GXGP"
|
||||
|
||||
async def crawl(self, **kwargs):
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
total_count=10,
|
||||
new_count=5,
|
||||
announcements=[],
|
||||
)
|
||||
|
||||
def get_pipeline_config(self):
|
||||
return PipelineConfig(
|
||||
filter_enabled=True,
|
||||
keywords=["大化"],
|
||||
notify_mode="filtered",
|
||||
)
|
||||
|
||||
|
||||
class MockDahuagovSpider(BaseSpider):
|
||||
name = "mock_dahuagov"
|
||||
source_code = "mock_dahuagov"
|
||||
source_name = "Mock Dahuagov"
|
||||
|
||||
async def crawl(self, **kwargs):
|
||||
return CrawlResult(
|
||||
source_code=self.source_code,
|
||||
source_name=self.source_name,
|
||||
total_count=3,
|
||||
new_count=3,
|
||||
announcements=[],
|
||||
)
|
||||
|
||||
def get_pipeline_config(self):
|
||||
return PipelineConfig(
|
||||
filter_enabled=False,
|
||||
notify_mode="all",
|
||||
mark_sent=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_service_registers_spiders():
|
||||
service = CrawlService()
|
||||
service.register(MockGXGPSpider())
|
||||
service.register(MockDahuagovSpider())
|
||||
assert len(service.spiders) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_service_run_all():
|
||||
service = CrawlService()
|
||||
service.register(MockGXGPSpider())
|
||||
service.register(MockDahuagovSpider())
|
||||
results = await service.run_all()
|
||||
assert len(results) == 2
|
||||
assert results[0].total_count == 10
|
||||
assert results[1].total_count == 3
|
||||
assert all(r.success for r in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_service_run_specific():
|
||||
service = CrawlService()
|
||||
service.register(MockGXGPSpider())
|
||||
service.register(MockDahuagovSpider())
|
||||
results = await service.run_spider("mock_dahuagov")
|
||||
assert len(results) == 1
|
||||
assert results[0].source_code == "mock_dahuagov"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_service_run_unknown():
|
||||
service = CrawlService()
|
||||
results = await service.run_spider("nonexistent")
|
||||
assert len(results) == 1
|
||||
assert results[0].success is False
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.filter_service import filter_by_date, filter_by_keywords
|
||||
|
||||
|
||||
def test_filter_by_keywords_match():
|
||||
announcements = [
|
||||
{"title": "大化县采购公告", "purchase_name": "大化县财政局",
|
||||
"source_code": "test", "source_name": "test"},
|
||||
{"title": "南宁市采购公告", "purchase_name": "南宁市财政局",
|
||||
"source_code": "test", "source_name": "test"},
|
||||
]
|
||||
result = filter_by_keywords(announcements, ["大化"])
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "大化县采购公告"
|
||||
|
||||
|
||||
def test_filter_by_keywords_no_keywords():
|
||||
announcements = [
|
||||
{"title": "大化县采购公告", "purchase_name": "x",
|
||||
"source_code": "test", "source_name": "test"},
|
||||
]
|
||||
result = filter_by_keywords(announcements, [])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_filter_by_date_range():
|
||||
today = datetime(2026, 5, 9)
|
||||
announcements = [
|
||||
{"title": "t1", "publish_date": datetime(2026, 5, 9),
|
||||
"source_code": "test", "source_name": "test"},
|
||||
{"title": "t2", "publish_date": datetime(2026, 5, 1),
|
||||
"source_code": "test", "source_name": "test"},
|
||||
{"title": "t3", "publish_date": datetime(2026, 4, 30),
|
||||
"source_code": "test", "source_name": "test"},
|
||||
]
|
||||
result = filter_by_date(announcements, start_date=today, end_date=today)
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "t1"
|
||||
@@ -0,0 +1,42 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_send():
|
||||
svc = NotificationService()
|
||||
announcements = [
|
||||
{
|
||||
"title": "测试公告",
|
||||
"publish_date": None,
|
||||
"purchase_name": "测试单位",
|
||||
"content_url": "https://x.com/1",
|
||||
"source_code": "test",
|
||||
"source_name": "测试来源",
|
||||
"announcement_type": "purchase",
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(svc.client, "send_textcard", AsyncMock(return_value=True)):
|
||||
count = await svc.send(announcements)
|
||||
assert count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_send_empty():
|
||||
svc = NotificationService()
|
||||
count = await svc.send([])
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_send_disabled():
|
||||
svc = NotificationService()
|
||||
announcements = [{"title": "test"}]
|
||||
|
||||
with patch.object(svc.client, "send_textcard", AsyncMock(return_value=False)):
|
||||
count = await svc.send(announcements)
|
||||
assert count == 0
|
||||
@@ -0,0 +1,95 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crawler.base import CrawlResult, PipelineConfig
|
||||
from app.services.pipeline import PostCrawlPipeline
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_filtered_mode():
|
||||
config = PipelineConfig(
|
||||
filter_enabled=True,
|
||||
keywords=["大化"],
|
||||
dedup_enabled=True,
|
||||
notify_mode="filtered",
|
||||
mark_sent=False,
|
||||
)
|
||||
result = CrawlResult(
|
||||
source_code="test", source_name="test",
|
||||
total_count=3, new_count=3,
|
||||
announcements=[
|
||||
{
|
||||
"title": "大化县公告", "publish_date": datetime(2026, 5, 9),
|
||||
"purchase_name": "大化县", "content_url": "https://1.com",
|
||||
"source_code": "test", "source_name": "测试",
|
||||
"announcement_type": "purchase", "crawl_mode": "auto",
|
||||
"is_new": True, "is_today": True,
|
||||
"content_hash": "abc123",
|
||||
},
|
||||
{
|
||||
"title": "南宁市公告", "publish_date": datetime(2026, 5, 9),
|
||||
"purchase_name": "南宁市", "content_url": "https://2.com",
|
||||
"source_code": "test", "source_name": "测试",
|
||||
"announcement_type": "purchase", "crawl_mode": "auto",
|
||||
"is_new": True, "is_today": True,
|
||||
"content_hash": "def456",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_notify = AsyncMock()
|
||||
|
||||
pipeline = PostCrawlPipeline(db_session=mock_db, notification_service=mock_notify)
|
||||
|
||||
with patch.object(pipeline, "_save_to_db", AsyncMock(return_value=2)):
|
||||
with patch.object(pipeline, "_send_notifications", AsyncMock(return_value=1)):
|
||||
pipe_result = await pipeline.process(
|
||||
result.announcements, config
|
||||
)
|
||||
assert pipe_result.stored == 2
|
||||
assert pipe_result.filtered == 1
|
||||
assert pipe_result.notified == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_all_mode():
|
||||
config = PipelineConfig(
|
||||
filter_enabled=False,
|
||||
keywords=[],
|
||||
dedup_enabled=True,
|
||||
notify_mode="all",
|
||||
mark_sent=True,
|
||||
)
|
||||
result = CrawlResult(
|
||||
source_code="dahuagov", source_name="大化县政府网",
|
||||
total_count=2, new_count=2,
|
||||
announcements=[
|
||||
{
|
||||
"title": f"公告{i}", "publish_date": datetime(2026, 5, 9),
|
||||
"purchase_name": "大化县", "content_url": f"https://x.com/{i}",
|
||||
"source_code": "dahuagov", "source_name": "大化县政府网采购公告",
|
||||
"announcement_type": "purchase", "crawl_mode": "auto",
|
||||
"is_new": True, "is_today": True,
|
||||
"content_hash": f"hash{i}",
|
||||
}
|
||||
for i in range(2)
|
||||
],
|
||||
)
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_notify = AsyncMock()
|
||||
pipeline = PostCrawlPipeline(db_session=mock_db, notification_service=mock_notify)
|
||||
|
||||
with patch.object(pipeline, "_save_to_db", AsyncMock(return_value=2)):
|
||||
with patch.object(pipeline, "_send_notifications", AsyncMock(return_value=2)):
|
||||
with patch.object(pipeline, "_mark_sent", AsyncMock(return_value=2)):
|
||||
pipe_result = await pipeline.process(
|
||||
result.announcements, config
|
||||
)
|
||||
assert pipe_result.stored == 2
|
||||
assert pipe_result.filtered == 0
|
||||
assert pipe_result.notified == 2
|
||||
pipeline._mark_sent.assert_awaited_once()
|
||||
Reference in New Issue
Block a user